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,1052 @@
1
+ """Per-user CUCM-vs-Webex migration diff report.
2
+
3
+ Joins 10+ object types from the post-analyze SQLite store to produce
4
+ a side-by-side comparison of what each user has in CUCM vs. what they'll
5
+ have in Webex Calling. Outputs self-contained HTML or flat CSV.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import html
11
+ import io
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Dataclasses
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass
21
+ class ForwardingRuleDiff:
22
+ """Single forwarding rule comparison."""
23
+ rule_type: str # "always", "busy", "no_answer", "busy_internal", etc.
24
+ cucm_enabled: bool
25
+ cucm_destination: str | None
26
+ webex_enabled: bool
27
+ webex_destination: str | None
28
+ status: str # "mapped", "lossy", "not_mapped"
29
+
30
+
31
+ @dataclass
32
+ class ForwardingDiff:
33
+ """Detailed call forwarding comparison."""
34
+ rules: list[ForwardingRuleDiff] = field(default_factory=list)
35
+
36
+
37
+ @dataclass
38
+ class UserDecisionSummary:
39
+ """Compact decision summary for the diff table."""
40
+ decision_id: str
41
+ type: str
42
+ severity: str
43
+ summary: str
44
+ resolution: str # "auto-resolved: {option}" or "pending"
45
+
46
+
47
+ @dataclass
48
+ class UserDiffRecord:
49
+ """Complete CUCM-vs-Webex diff for a single user."""
50
+ # Identity
51
+ canonical_id: str
52
+ display_name: str
53
+ email: str | None = None
54
+ cucm_userid: str | None = None
55
+
56
+ # Extension & Numbers
57
+ extension: str | None = None
58
+ did: str | None = None
59
+ extension_change: str = "unchanged"
60
+
61
+ # Location
62
+ cucm_location: str | None = None
63
+ webex_location: str | None = None
64
+
65
+ # Device
66
+ cucm_device_model: str | None = None
67
+ cucm_device_protocol: str | None = None
68
+ webex_device_model: str | None = None
69
+ device_tier: str | None = None
70
+ device_action: str = "no_change"
71
+
72
+ # Call Forwarding
73
+ forwarding: ForwardingDiff | None = None
74
+
75
+ # Voicemail
76
+ cucm_voicemail: str | None = None
77
+ webex_voicemail: str | None = None
78
+ voicemail_greeting_action: str = "not_applicable"
79
+
80
+ # BLF / Monitoring
81
+ blf_count_cucm: int = 0
82
+ blf_count_webex: int = 0
83
+ blf_mapped: int = 0
84
+ blf_unmapped: int = 0
85
+
86
+ # Speed Dials
87
+ speed_dial_count_cucm: int = 0
88
+ speed_dial_count_webex: int = 0
89
+
90
+ # Shared Lines
91
+ shared_line_dns: list[str] = field(default_factory=list)
92
+ shared_line_action: str = "none"
93
+
94
+ # Button Layout
95
+ total_buttons_cucm: int = 0
96
+ mapped_buttons_webex: int = 0
97
+ unmapped_buttons: list[str] = field(default_factory=list)
98
+
99
+ # Calling Permissions
100
+ cucm_css: str | None = None
101
+ webex_permissions: str | None = None
102
+
103
+ # Decisions
104
+ decisions: list[UserDecisionSummary] = field(default_factory=list)
105
+
106
+ # Summary flags
107
+ has_changes: bool = False
108
+ change_categories: list[str] = field(default_factory=list)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Index helpers — build O(1) lookup maps from bulk store queries
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _index_by_field(
116
+ objects: list[dict[str, Any]], key_field: str,
117
+ ) -> dict[str, list[dict[str, Any]]]:
118
+ """Group objects by a field value. Returns {field_value: [obj, ...]}."""
119
+ index: dict[str, list[dict[str, Any]]] = {}
120
+ for obj in objects:
121
+ key = obj.get(key_field)
122
+ if key:
123
+ index.setdefault(key, []).append(obj)
124
+ return index
125
+
126
+
127
+ def _index_decisions_by_user(
128
+ decisions: list[dict[str, Any]],
129
+ ) -> dict[str, list[dict[str, Any]]]:
130
+ """Index decisions by affected user canonical_id.
131
+
132
+ Checks context dict for: user_canonical_id, affected_user,
133
+ owner_canonical_id, and affected_objects (list).
134
+ """
135
+ index: dict[str, list[dict[str, Any]]] = {}
136
+ for dec in decisions:
137
+ ctx = dec.get("context") or {}
138
+ user_ids: set[str] = set()
139
+ for key in ("user_canonical_id", "affected_user", "owner_canonical_id"):
140
+ val = ctx.get(key)
141
+ if val and isinstance(val, str) and val.startswith("user:"):
142
+ user_ids.add(val)
143
+ affected = ctx.get("affected_objects")
144
+ if isinstance(affected, list):
145
+ for item in affected:
146
+ if isinstance(item, str) and item.startswith("user:"):
147
+ user_ids.add(item)
148
+ for uid in user_ids:
149
+ index.setdefault(uid, []).append(dec)
150
+ return index
151
+
152
+
153
+ def _index_shared_lines_by_user(
154
+ shared_lines: list[dict[str, Any]],
155
+ ) -> dict[str, list[dict[str, Any]]]:
156
+ """Index shared lines by member user canonical_id."""
157
+ index: dict[str, list[dict[str, Any]]] = {}
158
+ for sl in shared_lines:
159
+ owners = sl.get("owner_canonical_ids") or []
160
+ for uid in owners:
161
+ index.setdefault(uid, []).append(sl)
162
+ return index
163
+
164
+
165
+ def _index_permissions_by_user(
166
+ permissions: list[dict[str, Any]],
167
+ ) -> dict[str, list[dict[str, Any]]]:
168
+ """Index calling permissions by assigned user canonical_id."""
169
+ index: dict[str, list[dict[str, Any]]] = {}
170
+ for perm in permissions:
171
+ users = perm.get("assigned_users") or []
172
+ for uid in users:
173
+ index.setdefault(uid, []).append(perm)
174
+ return index
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Per-field diff builders
179
+ # ---------------------------------------------------------------------------
180
+
181
+ # Forwarding rule types that Webex Calling supports (map 1:1)
182
+ _WEBEX_FORWARDING_TYPES = {"always", "busy", "no_answer"}
183
+
184
+
185
+ def _build_forwarding_diff(
186
+ fwd: dict[str, Any] | None,
187
+ ) -> ForwardingDiff | None:
188
+ """Build forwarding diff from a call_forwarding store dict."""
189
+ if fwd is None:
190
+ return None
191
+
192
+ rules: list[ForwardingRuleDiff] = []
193
+
194
+ all_types = [
195
+ ("always", "always_enabled", "always_destination"),
196
+ ("busy", "busy_enabled", "busy_destination"),
197
+ ("no_answer", "no_answer_enabled", "no_answer_destination"),
198
+ ("busy_internal", "busy_internal_enabled", "busy_internal_destination"),
199
+ ("no_answer_internal", "no_answer_internal_enabled", "no_answer_internal_destination"),
200
+ ("no_coverage", "no_coverage_enabled", "no_coverage_destination"),
201
+ ("on_failure", "on_failure_enabled", "on_failure_destination"),
202
+ ("not_registered", "not_registered_enabled", "not_registered_destination"),
203
+ ]
204
+
205
+ has_any = False
206
+ for rule_type, enabled_key, dest_key in all_types:
207
+ enabled = fwd.get(enabled_key, False)
208
+ dest = fwd.get(dest_key)
209
+ if not enabled and not dest:
210
+ continue
211
+ has_any = True
212
+
213
+ if rule_type in _WEBEX_FORWARDING_TYPES:
214
+ to_vm_key = f"{rule_type}_to_voicemail"
215
+ to_vm = fwd.get(to_vm_key, False)
216
+ webex_dest = "Voicemail" if to_vm else dest
217
+ ring_count = fwd.get("no_answer_ring_count") if rule_type == "no_answer" else None
218
+ if ring_count and webex_dest:
219
+ webex_dest = f"{webex_dest} ({ring_count} rings)"
220
+ rules.append(ForwardingRuleDiff(
221
+ rule_type=rule_type,
222
+ cucm_enabled=enabled,
223
+ cucm_destination=dest,
224
+ webex_enabled=enabled,
225
+ webex_destination=webex_dest,
226
+ status="mapped",
227
+ ))
228
+ else:
229
+ rules.append(ForwardingRuleDiff(
230
+ rule_type=rule_type,
231
+ cucm_enabled=enabled,
232
+ cucm_destination=dest,
233
+ webex_enabled=False,
234
+ webex_destination=None,
235
+ status="not_mapped",
236
+ ))
237
+
238
+ return ForwardingDiff(rules=rules) if has_any else None
239
+
240
+
241
+ def _determine_device_action(tier: str | None) -> str:
242
+ """Map compatibility tier to a human-readable action."""
243
+ return {
244
+ "native_mpp": "no_change",
245
+ "convertible": "firmware_upgrade",
246
+ "webex_app": "webex_app",
247
+ "incompatible": "replace",
248
+ }.get(tier or "", "no_change")
249
+
250
+
251
+ def _determine_webex_device_model(
252
+ cucm_model: str | None, tier: str | None,
253
+ ) -> str | None:
254
+ """Determine what device model the user will have in Webex."""
255
+ if not cucm_model:
256
+ return None
257
+ if tier in ("native_mpp", "convertible"):
258
+ return cucm_model
259
+ if tier == "webex_app":
260
+ return "Webex App"
261
+ if tier == "incompatible":
262
+ return "New device TBD"
263
+ return cucm_model
264
+
265
+
266
+ def _format_perm_summary(perm: dict[str, Any]) -> str:
267
+ """Format calling permissions into a short summary string."""
268
+ entries = perm.get("calling_permissions") or []
269
+ allowed = []
270
+ for e in entries:
271
+ if isinstance(e, dict) and e.get("action") == "ALLOW":
272
+ allowed.append(e["call_type"].replace("_", " ").title())
273
+ if not allowed:
274
+ return "All blocked"
275
+ return " + ".join(allowed[:4])
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Core join logic
280
+ # ---------------------------------------------------------------------------
281
+
282
+ def _build_single_user_diff(
283
+ user: dict[str, Any],
284
+ device_by_owner: dict[str, list[dict[str, Any]]],
285
+ fwd_by_user: dict[str, list[dict[str, Any]]],
286
+ vm_by_user: dict[str, list[dict[str, Any]]],
287
+ monitor_by_user: dict[str, list[dict[str, Any]]],
288
+ layout_by_owner: dict[str, list[dict[str, Any]]],
289
+ location_by_id: dict[str, dict[str, Any]],
290
+ decisions_by_user: dict[str, list[dict[str, Any]]],
291
+ shared_by_user: dict[str, list[dict[str, Any]]],
292
+ perms_by_user: dict[str, list[dict[str, Any]]],
293
+ css_by_user: dict[str, str],
294
+ ) -> UserDiffRecord:
295
+ """Build a UserDiffRecord for a single user."""
296
+ uid = user["canonical_id"]
297
+ changes: list[str] = []
298
+
299
+ # --- Identity ---
300
+ emails = user.get("emails") or []
301
+ email = emails[0] if emails else None
302
+ first = user.get("first_name") or ""
303
+ last = user.get("last_name") or ""
304
+ display_name = user.get("display_name") or f"{first} {last}".strip() or uid
305
+
306
+ # --- Location ---
307
+ loc_id = user.get("location_id")
308
+ loc = location_by_id.get(loc_id) if loc_id else None
309
+ cucm_loc = None
310
+ webex_loc = None
311
+ if loc:
312
+ webex_loc = loc.get("name")
313
+ pools = loc.get("cucm_device_pool_names") or []
314
+ cucm_loc = pools[0] if pools else loc.get("cucm_location_name")
315
+
316
+ # --- Device ---
317
+ devices = device_by_owner.get(uid, [])
318
+ dev = devices[0] if devices else None
319
+ cucm_model = dev.get("model") if dev else None
320
+ cucm_protocol = dev.get("cucm_protocol") if dev else None
321
+ tier = dev.get("compatibility_tier") if dev else None
322
+ webex_model = _determine_webex_device_model(cucm_model, tier)
323
+ device_action = _determine_device_action(tier)
324
+ if device_action != "no_change" and cucm_model:
325
+ changes.append("device")
326
+
327
+ # --- Call Forwarding ---
328
+ fwd_list = fwd_by_user.get(uid, [])
329
+ fwd = fwd_list[0] if fwd_list else None
330
+ forwarding = _build_forwarding_diff(fwd)
331
+ if forwarding and any(r.status == "not_mapped" for r in forwarding.rules):
332
+ changes.append("forwarding")
333
+
334
+ # --- Voicemail ---
335
+ vm_list = vm_by_user.get(uid, [])
336
+ vm = vm_list[0] if vm_list else None
337
+ cucm_vm = None
338
+ webex_vm = None
339
+ greeting_action = "not_applicable"
340
+ if vm:
341
+ enabled = vm.get("enabled", True)
342
+ profile_name = vm.get("cucm_voicemail_profile_name") or "Unity"
343
+ cucm_vm = f"Enabled ({profile_name})" if enabled else "Disabled"
344
+ webex_vm = "Enabled (Webex VM)" if enabled else "Disabled"
345
+ if enabled:
346
+ greeting_action = "re-record"
347
+ changes.append("voicemail")
348
+
349
+ # --- Monitoring / BLF ---
350
+ mon_list = monitor_by_user.get(uid, [])
351
+ mon = mon_list[0] if mon_list else None
352
+ blf_cucm = 0
353
+ blf_mapped = 0
354
+ blf_unmapped = 0
355
+ if mon:
356
+ members = mon.get("monitored_members") or []
357
+ blf_cucm = len(members)
358
+ blf_mapped = sum(1 for m in members if m.get("mapped", False))
359
+ blf_unmapped = blf_cucm - blf_mapped
360
+ if blf_unmapped > 0:
361
+ changes.append("monitoring")
362
+
363
+ # --- Device Layout ---
364
+ layouts = layout_by_owner.get(uid, [])
365
+ layout = layouts[0] if layouts else None
366
+ total_buttons = 0
367
+ mapped_buttons = 0
368
+ speed_cucm = 0
369
+ speed_webex = 0
370
+ unmapped_descs: list[str] = []
371
+ if layout:
372
+ keys = layout.get("resolved_line_keys") or []
373
+ total_buttons = len(keys)
374
+ mapped_buttons = total_buttons
375
+ sds = layout.get("speed_dials") or []
376
+ speed_cucm = len(sds)
377
+ speed_webex = speed_cucm
378
+ um = layout.get("unmapped_buttons") or []
379
+ unmapped_descs = [b.get("label") or b.get("type", "Unknown") for b in um]
380
+ total_buttons += len(um)
381
+ if unmapped_descs:
382
+ changes.append("buttons")
383
+
384
+ # --- Shared Lines ---
385
+ shared_list = shared_by_user.get(uid, [])
386
+ shared_dns: list[str] = []
387
+ shared_action = "none"
388
+ for sl in shared_list:
389
+ dn_id = sl.get("dn_canonical_id") or ""
390
+ shared_dns.append(dn_id)
391
+ if shared_dns:
392
+ shared_action = "virtual_line"
393
+ changes.append("shared_lines")
394
+
395
+ # --- Calling Permissions ---
396
+ perm_list = perms_by_user.get(uid, [])
397
+ perm = perm_list[0] if perm_list else None
398
+ cucm_css = css_by_user.get(uid)
399
+ webex_perms = _format_perm_summary(perm) if perm else None
400
+ if cucm_css and webex_perms:
401
+ changes.append("permissions")
402
+
403
+ # --- Decisions ---
404
+ user_decs = decisions_by_user.get(uid, [])
405
+ dec_summaries: list[UserDecisionSummary] = []
406
+ for d in user_decs:
407
+ chosen = d.get("chosen_option")
408
+ resolution = f"auto-resolved: {chosen}" if chosen else "pending"
409
+ dec_summaries.append(UserDecisionSummary(
410
+ decision_id=d.get("decision_id", ""),
411
+ type=d.get("type", ""),
412
+ severity=d.get("severity", ""),
413
+ summary=d.get("summary", ""),
414
+ resolution=resolution,
415
+ ))
416
+ if any(ds.resolution == "pending" for ds in dec_summaries):
417
+ changes.append("decisions")
418
+
419
+ return UserDiffRecord(
420
+ canonical_id=uid,
421
+ display_name=display_name,
422
+ email=email,
423
+ cucm_userid=user.get("cucm_userid"),
424
+ extension=user.get("extension"),
425
+ did=None,
426
+ extension_change="unchanged",
427
+ cucm_location=cucm_loc,
428
+ webex_location=webex_loc,
429
+ cucm_device_model=cucm_model,
430
+ cucm_device_protocol=cucm_protocol,
431
+ webex_device_model=webex_model,
432
+ device_tier=tier,
433
+ device_action=device_action,
434
+ forwarding=forwarding,
435
+ cucm_voicemail=cucm_vm,
436
+ webex_voicemail=webex_vm,
437
+ voicemail_greeting_action=greeting_action,
438
+ blf_count_cucm=blf_cucm,
439
+ blf_count_webex=blf_mapped,
440
+ blf_mapped=blf_mapped,
441
+ blf_unmapped=blf_unmapped,
442
+ speed_dial_count_cucm=speed_cucm,
443
+ speed_dial_count_webex=speed_webex,
444
+ shared_line_dns=shared_dns,
445
+ shared_line_action=shared_action,
446
+ total_buttons_cucm=total_buttons,
447
+ mapped_buttons_webex=mapped_buttons,
448
+ unmapped_buttons=unmapped_descs,
449
+ cucm_css=cucm_css,
450
+ webex_permissions=webex_perms,
451
+ decisions=dec_summaries,
452
+ has_changes=len(changes) > 0,
453
+ change_categories=changes,
454
+ )
455
+
456
+
457
+ def build_user_diffs(
458
+ store: Any,
459
+ user_filter: str | None = None,
460
+ location_filter: str | None = None,
461
+ ) -> list[UserDiffRecord]:
462
+ """Build per-user diff records from the migration store.
463
+
464
+ Args:
465
+ store: MigrationStore instance (post-analyze state).
466
+ user_filter: If set, only return this single user's canonical_id.
467
+ location_filter: If set, only return users in this location canonical_id.
468
+
469
+ Returns:
470
+ List of UserDiffRecord sorted by display_name.
471
+ """
472
+ # 1. Bulk load all relevant object types
473
+ users = store.get_objects("user")
474
+ devices = store.get_objects("device")
475
+ call_fwds = store.get_objects("call_forwarding")
476
+ vm_profiles = store.get_objects("voicemail_profile")
477
+ monitoring = store.get_objects("monitoring_list")
478
+ layouts = store.get_objects("device_layout")
479
+ shared_lines = store.get_objects("shared_line")
480
+ permissions = store.get_objects("calling_permission")
481
+ decisions = store.get_all_decisions()
482
+ locations = store.get_objects("location")
483
+
484
+ # 2. Build index maps for O(1) lookups
485
+ device_by_owner = _index_by_field(devices, "owner_canonical_id")
486
+ fwd_by_user = _index_by_field(call_fwds, "user_canonical_id")
487
+ monitor_by_user = _index_by_field(monitoring, "user_canonical_id")
488
+ layout_by_owner = _index_by_field(layouts, "owner_canonical_id")
489
+ location_by_id = {loc["canonical_id"]: loc for loc in locations}
490
+
491
+ # Voicemail profiles may be indexed by user_canonical_id field OR
492
+ # wired via user_has_voicemail cross-refs — support both patterns.
493
+ vm_profile_by_id = {p["canonical_id"]: p for p in vm_profiles}
494
+ vm_by_user: dict[str, list[dict[str, Any]]] = _index_by_field(
495
+ vm_profiles, "user_canonical_id"
496
+ )
497
+ try:
498
+ vm_refs = store.get_cross_refs(relationship="user_has_voicemail")
499
+ for ref in vm_refs:
500
+ uid_ref = ref["from_id"]
501
+ profile = vm_profile_by_id.get(ref["to_id"])
502
+ if profile:
503
+ vm_by_user.setdefault(uid_ref, []).append(profile)
504
+ except Exception:
505
+ pass
506
+
507
+ # 3. Index decisions, shared lines, permissions by user
508
+ decisions_by_user = _index_decisions_by_user(decisions)
509
+ shared_by_user = _index_shared_lines_by_user(shared_lines)
510
+ perms_by_user = _index_permissions_by_user(permissions)
511
+
512
+ # 4. Build CSS-by-user map from cross-refs
513
+ css_by_user: dict[str, str] = {}
514
+ try:
515
+ css_refs = store.get_cross_refs(relationship="user_has_css")
516
+ for ref in css_refs:
517
+ css_by_user[ref["from_id"]] = ref["to_id"].split(":", 1)[-1]
518
+ except Exception:
519
+ pass # cross-ref may not exist
520
+
521
+ # 5. Apply filters
522
+ if user_filter:
523
+ users = [u for u in users if u["canonical_id"] == user_filter]
524
+ if location_filter:
525
+ users = [u for u in users if u.get("location_id") == location_filter]
526
+
527
+ # 6. Build diff record for each user
528
+ results: list[UserDiffRecord] = []
529
+ for user in users:
530
+ record = _build_single_user_diff(
531
+ user, device_by_owner, fwd_by_user, vm_by_user,
532
+ monitor_by_user, layout_by_owner, location_by_id,
533
+ decisions_by_user, shared_by_user, perms_by_user,
534
+ css_by_user,
535
+ )
536
+ results.append(record)
537
+
538
+ # 7. Sort by display name
539
+ results.sort(key=lambda r: (r.display_name or "").lower())
540
+ return results
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # CSV Renderer
545
+ # ---------------------------------------------------------------------------
546
+
547
+ def _diff_status(cucm_val: str | None, webex_val: str | None) -> str:
548
+ """Determine diff status between CUCM and Webex values."""
549
+ if not cucm_val and not webex_val:
550
+ return "unchanged"
551
+ if not cucm_val and webex_val:
552
+ return "new"
553
+ if cucm_val and not webex_val:
554
+ return "removed"
555
+ if cucm_val == webex_val:
556
+ return "unchanged"
557
+ return "changed"
558
+
559
+
560
+ def render_csv(records: list[UserDiffRecord]) -> str:
561
+ """Render diff records as CSV with UTF-8 BOM.
562
+
563
+ One row per user per setting category.
564
+ """
565
+ output = io.StringIO()
566
+ output.write("\ufeff") # UTF-8 BOM for Excel
567
+ writer = csv.writer(output)
568
+
569
+ # Header
570
+ writer.writerow([
571
+ "User", "Email", "Extension", "Category",
572
+ "CUCM_Value", "Webex_Value", "Status", "Decision_ID",
573
+ ])
574
+
575
+ for r in records:
576
+ base = [r.display_name, r.email or "", r.extension or ""]
577
+
578
+ # Device
579
+ cucm_dev = (f"{r.cucm_device_model} ({r.cucm_device_protocol})"
580
+ if r.cucm_device_model else "")
581
+ webex_dev = r.webex_device_model or ""
582
+ writer.writerow(base + [
583
+ "Phone Model", cucm_dev, webex_dev,
584
+ _diff_status(r.cucm_device_model, r.webex_device_model), "",
585
+ ])
586
+
587
+ if r.device_tier:
588
+ tier_display = r.device_tier.replace("_", " ").title()
589
+ writer.writerow(base + [
590
+ "Compatibility", "--", f"{tier_display} ({r.device_action})",
591
+ "new", "",
592
+ ])
593
+
594
+ # Extension
595
+ writer.writerow(base + [
596
+ "Extension", r.extension or "", r.extension or "",
597
+ r.extension_change, "",
598
+ ])
599
+
600
+ # DID
601
+ if r.did:
602
+ writer.writerow(base + [
603
+ "DID", r.did, r.did, "unchanged", "",
604
+ ])
605
+
606
+ # Location
607
+ writer.writerow(base + [
608
+ "Location", r.cucm_location or "", r.webex_location or "",
609
+ _diff_status(r.cucm_location, r.webex_location), "",
610
+ ])
611
+
612
+ # Voicemail
613
+ if r.cucm_voicemail or r.webex_voicemail:
614
+ writer.writerow(base + [
615
+ "Voicemail", r.cucm_voicemail or "Disabled",
616
+ r.webex_voicemail or "Disabled",
617
+ _diff_status(r.cucm_voicemail, r.webex_voicemail), "",
618
+ ])
619
+ if r.voicemail_greeting_action == "re-record":
620
+ writer.writerow(base + [
621
+ "Voicemail Greeting", "--", "RE-RECORD",
622
+ "action_required", "",
623
+ ])
624
+
625
+ # Forwarding rules
626
+ if r.forwarding:
627
+ for rule in r.forwarding.rules:
628
+ cucm_dest = rule.cucm_destination or (
629
+ "Enabled" if rule.cucm_enabled else "Off")
630
+ if rule.status == "not_mapped":
631
+ webex_dest = "NOT MAPPED"
632
+ else:
633
+ webex_dest = rule.webex_destination or (
634
+ "Enabled" if rule.webex_enabled else "Off")
635
+ label = f"Fwd {rule.rule_type.replace('_', ' ').title()}"
636
+ writer.writerow(base + [
637
+ label, cucm_dest, webex_dest, rule.status, "",
638
+ ])
639
+
640
+ # BLF
641
+ if r.blf_count_cucm > 0:
642
+ writer.writerow(base + [
643
+ "BLF Keys", str(r.blf_count_cucm), str(r.blf_mapped),
644
+ "unchanged" if r.blf_unmapped == 0 else "changed", "",
645
+ ])
646
+
647
+ # Speed Dials
648
+ if r.speed_dial_count_cucm > 0:
649
+ writer.writerow(base + [
650
+ "Speed Dials", str(r.speed_dial_count_cucm),
651
+ str(r.speed_dial_count_webex), "unchanged", "",
652
+ ])
653
+
654
+ # Shared Lines
655
+ if r.shared_line_dns:
656
+ writer.writerow(base + [
657
+ "Shared Lines", str(len(r.shared_line_dns)),
658
+ r.shared_line_action, "changed", "",
659
+ ])
660
+
661
+ # Unmapped Buttons
662
+ if r.unmapped_buttons:
663
+ writer.writerow(base + [
664
+ "Unmapped Buttons", str(len(r.unmapped_buttons)),
665
+ "; ".join(r.unmapped_buttons), "action_required", "",
666
+ ])
667
+
668
+ # Calling Permissions
669
+ if r.cucm_css or r.webex_permissions:
670
+ writer.writerow(base + [
671
+ "Calling Permissions", r.cucm_css or "",
672
+ r.webex_permissions or "",
673
+ _diff_status(r.cucm_css, r.webex_permissions), "",
674
+ ])
675
+
676
+ # Decisions
677
+ for dec in r.decisions:
678
+ writer.writerow(base + [
679
+ f"Decision: {dec.type}", dec.summary,
680
+ dec.resolution, dec.severity.lower(), dec.decision_id,
681
+ ])
682
+
683
+ return output.getvalue()
684
+
685
+
686
+ # ---------------------------------------------------------------------------
687
+ # HTML Renderer
688
+ # ---------------------------------------------------------------------------
689
+
690
+ def _css_class_for_status(status: str) -> str:
691
+ """Map diff status to CSS class name."""
692
+ return {
693
+ "unchanged": "unchanged",
694
+ "changed": "changed",
695
+ "not_mapped": "not-mapped",
696
+ "new": "new",
697
+ "action_required": "action-required",
698
+ "mapped": "unchanged",
699
+ "lossy": "changed",
700
+ }.get(status, "")
701
+
702
+
703
+ def _render_diff_row(
704
+ setting: str, cucm: str, webex: str, status: str = "",
705
+ ) -> str:
706
+ """Render a single <tr> for the diff table."""
707
+ cls = _css_class_for_status(status)
708
+ cucm_e = html.escape(cucm)
709
+ webex_e = html.escape(webex)
710
+ return (
711
+ f'<tr><td>{html.escape(setting)}</td>'
712
+ f'<td class="{cls}">{cucm_e}</td>'
713
+ f'<td class="{cls}">{webex_e}</td></tr>\n'
714
+ )
715
+
716
+
717
+ def _render_user_section(r: UserDiffRecord) -> str:
718
+ """Render a single <details> section for one user."""
719
+ ext_display = f" &mdash; ext. {html.escape(r.extension)}" if r.extension else ""
720
+ email_display = f" ({html.escape(r.email)})" if r.email else ""
721
+ change_count = len(r.change_categories)
722
+ badge = ""
723
+ if change_count > 0:
724
+ badge = f' <span class="badge warning">{change_count} change{"s" if change_count != 1 else ""}</span>'
725
+ elif r.decisions:
726
+ badge = f' <span class="badge info">{len(r.decisions)} decision{"s" if len(r.decisions) != 1 else ""}</span>'
727
+
728
+ categories = ",".join(r.change_categories) if r.change_categories else "none"
729
+ has_decisions = "true" if r.decisions else "false"
730
+
731
+ lines: list[str] = []
732
+ lines.append(
733
+ f'<details class="user-diff" data-categories="{categories}" '
734
+ f'data-decisions="{has_decisions}">'
735
+ )
736
+ lines.append(
737
+ f'<summary><strong>{html.escape(r.display_name)}</strong>'
738
+ f'{email_display}{ext_display}{badge}</summary>'
739
+ )
740
+ lines.append('<table class="diff-table"><thead><tr>')
741
+ lines.append('<th>Setting</th><th>CUCM (Current)</th><th>Webex (Planned)</th>')
742
+ lines.append('</tr></thead><tbody>')
743
+
744
+ # Device
745
+ if r.cucm_device_model:
746
+ cucm_dev = f"{r.cucm_device_model} ({r.cucm_device_protocol or 'SIP'})"
747
+ webex_dev = r.webex_device_model or ""
748
+ lines.append(_render_diff_row(
749
+ "Phone Model", cucm_dev, webex_dev,
750
+ "changed" if cucm_dev != webex_dev else "unchanged",
751
+ ))
752
+ if r.device_tier:
753
+ tier_d = r.device_tier.replace("_", " ").title()
754
+ lines.append(_render_diff_row(
755
+ "Compatibility", "--", f"{tier_d}", "new",
756
+ ))
757
+
758
+ # Extension
759
+ if r.extension:
760
+ lines.append(_render_diff_row(
761
+ "Extension", r.extension, r.extension, r.extension_change,
762
+ ))
763
+
764
+ # DID
765
+ if r.did:
766
+ lines.append(_render_diff_row("DID", r.did, r.did, "unchanged"))
767
+
768
+ # Location
769
+ if r.cucm_location or r.webex_location:
770
+ lines.append(_render_diff_row(
771
+ "Location", r.cucm_location or "--", r.webex_location or "--",
772
+ _diff_status(r.cucm_location, r.webex_location),
773
+ ))
774
+
775
+ # Voicemail
776
+ if r.cucm_voicemail or r.webex_voicemail:
777
+ lines.append(_render_diff_row(
778
+ "Voicemail", r.cucm_voicemail or "Disabled",
779
+ r.webex_voicemail or "Disabled",
780
+ _diff_status(r.cucm_voicemail, r.webex_voicemail),
781
+ ))
782
+ if r.voicemail_greeting_action == "re-record":
783
+ lines.append(_render_diff_row(
784
+ "", "", "Greeting: RE-RECORD", "action_required",
785
+ ))
786
+
787
+ # Forwarding
788
+ if r.forwarding:
789
+ for rule in r.forwarding.rules:
790
+ cucm_dest = rule.cucm_destination or (
791
+ "Enabled" if rule.cucm_enabled else "Off")
792
+ if rule.status == "not_mapped":
793
+ webex_dest = "NOT MAPPED"
794
+ else:
795
+ webex_dest = rule.webex_destination or (
796
+ "Enabled" if rule.webex_enabled else "Off")
797
+ label = f"Fwd {rule.rule_type.replace('_', ' ').title()}"
798
+ lines.append(_render_diff_row(label, cucm_dest, webex_dest, rule.status))
799
+
800
+ # Shared Lines
801
+ if r.shared_line_dns:
802
+ lines.append(_render_diff_row(
803
+ "Shared Lines", str(len(r.shared_line_dns)),
804
+ r.shared_line_action.replace("_", " ").title(), "changed",
805
+ ))
806
+
807
+ # BLF
808
+ if r.blf_count_cucm > 0:
809
+ blf_status = "unchanged" if r.blf_unmapped == 0 else "changed"
810
+ detail = f"{r.blf_mapped} mapped"
811
+ if r.blf_unmapped:
812
+ detail += f", {r.blf_unmapped} unmapped"
813
+ lines.append(_render_diff_row(
814
+ "BLF Keys", str(r.blf_count_cucm), detail, blf_status,
815
+ ))
816
+
817
+ # Speed Dials
818
+ if r.speed_dial_count_cucm > 0:
819
+ lines.append(_render_diff_row(
820
+ "Speed Dials", str(r.speed_dial_count_cucm),
821
+ str(r.speed_dial_count_webex), "unchanged",
822
+ ))
823
+
824
+ # Unmapped Buttons
825
+ if r.unmapped_buttons:
826
+ lines.append(_render_diff_row(
827
+ "Unmapped Buttons", str(len(r.unmapped_buttons)),
828
+ "; ".join(r.unmapped_buttons), "action_required",
829
+ ))
830
+
831
+ # Calling Permissions
832
+ if r.cucm_css or r.webex_permissions:
833
+ lines.append(_render_diff_row(
834
+ "Calling Permissions", r.cucm_css or "--",
835
+ r.webex_permissions or "--",
836
+ _diff_status(r.cucm_css, r.webex_permissions),
837
+ ))
838
+
839
+ lines.append('</tbody></table>')
840
+
841
+ # Decisions section
842
+ if r.decisions:
843
+ lines.append('<div class="decisions-section"><h4>Decisions</h4><ul>')
844
+ for d in r.decisions:
845
+ sev_cls = d.severity.lower()
846
+ lines.append(
847
+ f'<li><span class="badge {sev_cls}">{html.escape(d.severity)}</span> '
848
+ f'{html.escape(d.decision_id)}: {html.escape(d.type)} &mdash; '
849
+ f'{html.escape(d.resolution)}</li>'
850
+ )
851
+ lines.append('</ul></div>')
852
+
853
+ lines.append('</details>')
854
+ return "\n".join(lines)
855
+
856
+
857
+ _USER_DIFF_CSS = """\
858
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
859
+ :root {
860
+ --warm-50: #fdf8f3; --warm-100: #f9efe4; --warm-200: #f0dcc6;
861
+ --slate-400: #8e97a5; --slate-500: #636e7e; --slate-700: #353d4a;
862
+ --slate-800: #242a33; --slate-900: #181c22;
863
+ --primary: #00897B; --primary-light: #E0F2F1;
864
+ --success: #2E7D32; --warning: #EF6C00; --critical: #C62828;
865
+ --font-display: 'Lora', Georgia, serif;
866
+ --font-body: 'Source Sans 3', system-ui, sans-serif;
867
+ --font-mono: 'IBM Plex Mono', monospace;
868
+ }
869
+ body { font-family: var(--font-body); background: var(--warm-50);
870
+ color: var(--slate-800); line-height: 1.5; padding: 0; }
871
+ .page-header { background: var(--slate-900); color: #fff; padding: 32px 40px; }
872
+ .page-header h1 { font-family: var(--font-display); font-size: 1.75rem;
873
+ font-weight: 600; margin-bottom: 4px; }
874
+ .page-header p { color: var(--slate-400); font-size: 0.9rem; }
875
+ .summary-bar { display: flex; gap: 24px; padding: 20px 40px;
876
+ background: #fff; border-bottom: 1px solid var(--warm-200); }
877
+ .summary-bar .stat { font-size: 0.95rem; color: var(--slate-700); }
878
+ .summary-bar .stat strong { font-size: 1.3rem; display: block;
879
+ color: var(--slate-800); }
880
+ .controls { display: flex; gap: 12px; padding: 16px 40px;
881
+ align-items: center; flex-wrap: wrap; }
882
+ .controls input { flex: 1; min-width: 200px; padding: 8px 12px;
883
+ border: 1px solid var(--warm-200); border-radius: 4px;
884
+ font-family: var(--font-body); font-size: 0.9rem; }
885
+ .controls select { padding: 8px 12px; border: 1px solid var(--warm-200);
886
+ border-radius: 4px; font-family: var(--font-body);
887
+ font-size: 0.9rem; background: #fff; }
888
+ .controls button { padding: 8px 16px; border: 1px solid var(--warm-200);
889
+ border-radius: 4px; background: #fff; cursor: pointer;
890
+ font-family: var(--font-body); font-size: 0.9rem; }
891
+ .controls button:hover { background: var(--warm-100); }
892
+ .user-list { padding: 16px 40px; }
893
+ .user-diff { border: 1px solid var(--warm-200); border-radius: 4px;
894
+ margin: 8px 0; background: #fff; }
895
+ .user-diff summary { padding: 12px 16px; cursor: pointer; display: flex;
896
+ align-items: center; gap: 12px; font-size: 0.95rem;
897
+ list-style: none; }
898
+ .user-diff summary::-webkit-details-marker { display: none; }
899
+ .user-diff summary::before { content: '\\25b6'; font-size: 0.7rem;
900
+ color: var(--slate-400); transition: transform 0.15s; }
901
+ .user-diff[open] summary::before { transform: rotate(90deg); }
902
+ .user-diff[open] summary { border-bottom: 1px solid var(--warm-200);
903
+ background: var(--warm-50); }
904
+ .diff-table { width: 100%; border-collapse: collapse; }
905
+ .diff-table th { text-align: left; text-transform: uppercase; font-size: 0.75rem;
906
+ letter-spacing: 0.05em; color: var(--slate-500);
907
+ padding: 8px 12px; border-bottom: 2px solid var(--warm-200); }
908
+ .diff-table td { padding: 6px 12px; border-bottom: 1px solid var(--warm-100);
909
+ font-size: 0.9rem; }
910
+ .diff-table .not-mapped { color: var(--critical); font-style: italic; }
911
+ .diff-table .unchanged { color: var(--slate-400); }
912
+ .diff-table .changed { color: var(--warning); font-weight: 600; }
913
+ .diff-table .new { color: var(--success); }
914
+ .diff-table .action-required { color: var(--primary); font-weight: 600; }
915
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
916
+ font-size: 0.75rem; font-weight: 600; }
917
+ .badge.warning { background: #FFF3E0; color: var(--warning); }
918
+ .badge.info { background: var(--primary-light); color: var(--primary); }
919
+ .badge.high, .badge.critical { background: #FFEBEE; color: var(--critical); }
920
+ .badge.medium { background: #FFF3E0; color: var(--warning); }
921
+ .badge.low { background: #E8F5E9; color: var(--success); }
922
+ .decisions-section { padding: 12px 16px; background: var(--warm-50);
923
+ border-top: 1px solid var(--warm-200); }
924
+ .decisions-section h4 { font-family: var(--font-display); font-size: 0.9rem;
925
+ margin-bottom: 8px; }
926
+ .decisions-section ul { list-style: none; }
927
+ .decisions-section li { padding: 4px 0; font-size: 0.85rem; }
928
+ .empty-state { text-align: center; padding: 60px; color: var(--slate-500);
929
+ font-size: 1.1rem; }
930
+ footer { padding: 24px 40px; text-align: center; color: var(--slate-400);
931
+ font-size: 0.8rem; border-top: 1px solid var(--warm-200);
932
+ margin-top: 32px; }
933
+ .hidden { display: none !important; }
934
+ @media print {
935
+ .controls, .page-header, footer { display: none; }
936
+ .user-diff[open] summary { background: transparent; }
937
+ .user-diff { break-inside: avoid; }
938
+ details[open] { display: block; }
939
+ }
940
+ """
941
+
942
+ _USER_DIFF_JS = """\
943
+ (function() {
944
+ var search = document.getElementById('search');
945
+ var filter = document.getElementById('filter-category');
946
+ var expandBtn = document.getElementById('expand-all');
947
+ var details = document.querySelectorAll('.user-diff');
948
+
949
+ function applyFilters() {
950
+ var q = search.value.toLowerCase();
951
+ var cat = filter.value;
952
+ details.forEach(function(el) {
953
+ var text = el.querySelector('summary').textContent.toLowerCase();
954
+ var cats = el.dataset.categories || '';
955
+ var hasDec = el.dataset.decisions === 'true';
956
+ var matchSearch = !q || text.indexOf(q) >= 0;
957
+ var matchCat = cat === 'all'
958
+ || (cat === 'no-change' && cats === 'none')
959
+ || (cat === 'decisions' && hasDec)
960
+ || cats.indexOf(cat) >= 0;
961
+ if (matchSearch && matchCat) {
962
+ el.classList.remove('hidden');
963
+ } else {
964
+ el.classList.add('hidden');
965
+ }
966
+ });
967
+ }
968
+
969
+ search.addEventListener('input', applyFilters);
970
+ filter.addEventListener('change', applyFilters);
971
+
972
+ var expanded = false;
973
+ expandBtn.addEventListener('click', function() {
974
+ expanded = !expanded;
975
+ details.forEach(function(el) {
976
+ if (!el.classList.contains('hidden')) el.open = expanded;
977
+ });
978
+ expandBtn.textContent = expanded ? 'Collapse All' : 'Expand All';
979
+ });
980
+ })();
981
+ """
982
+
983
+
984
+ def render_html(
985
+ records: list[UserDiffRecord],
986
+ brand: str = "",
987
+ ) -> str:
988
+ """Render diff records as a self-contained HTML document."""
989
+ change_count = sum(1 for r in records if r.has_changes)
990
+ no_change_count = len(records) - change_count
991
+ pending_count = sum(
992
+ 1 for r in records
993
+ for d in r.decisions if d.resolution == "pending"
994
+ )
995
+ date_str = __import__("datetime").date.today().isoformat()
996
+
997
+ user_sections = "\n".join(_render_user_section(r) for r in records)
998
+ if not records:
999
+ user_sections = '<p class="empty-state">No users found in the migration store.</p>'
1000
+
1001
+ return f'''<!DOCTYPE html>
1002
+ <html lang="en">
1003
+ <head>
1004
+ <meta charset="UTF-8">
1005
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1006
+ <title>Per-User Migration Diff &mdash; {html.escape(brand)}</title>
1007
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1008
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1009
+ <link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;600;700&family=Source+Sans+3:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
1010
+ <style>
1011
+ {_USER_DIFF_CSS}
1012
+ </style>
1013
+ </head>
1014
+ <body>
1015
+ <header class="page-header">
1016
+ <h1>Per-User Migration Diff</h1>
1017
+ <p>Generated {date_str} | {len(records)} users | {html.escape(brand)}</p>
1018
+ </header>
1019
+
1020
+ <div class="summary-bar">
1021
+ <div class="stat"><strong>{len(records)}</strong> Total Users</div>
1022
+ <div class="stat"><strong>{change_count}</strong> With Changes</div>
1023
+ <div class="stat"><strong>{no_change_count}</strong> No Changes</div>
1024
+ <div class="stat"><strong>{pending_count}</strong> Pending Decisions</div>
1025
+ </div>
1026
+
1027
+ <div class="controls">
1028
+ <input type="text" id="search" placeholder="Search by name, email, or extension...">
1029
+ <select id="filter-category">
1030
+ <option value="all">All Users</option>
1031
+ <option value="device">Device Changes</option>
1032
+ <option value="forwarding">Forwarding Changes</option>
1033
+ <option value="voicemail">Voicemail Changes</option>
1034
+ <option value="decisions">Has Decisions</option>
1035
+ <option value="no-change">No Changes</option>
1036
+ </select>
1037
+ <button id="expand-all" type="button">Expand All</button>
1038
+ </div>
1039
+
1040
+ <div class="user-list">
1041
+ {user_sections}
1042
+ </div>
1043
+
1044
+ <footer>
1045
+ <p>Generated by wxcli cucm user-diff</p>
1046
+ </footer>
1047
+
1048
+ <script>
1049
+ {_USER_DIFF_JS}
1050
+ </script>
1051
+ </body>
1052
+ </html>'''