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,964 @@
1
+ """Async bulk execution engine for CUCM migration operations.
2
+
3
+ Processes migration operations concurrently via aiohttp with:
4
+ - Semaphore-based rate limiting
5
+ - Automatic 429 retry with Retry-After backoff
6
+ - Per-operation status tracking via update_op_status()
7
+ - 409 auto-recovery (search for existing resource)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import aiohttp
18
+
19
+ from wxcli.migration.execute.handlers import HANDLER_REGISTRY, SkippedResult
20
+ from wxcli.migration.execute.runtime import get_next_batch, update_op_status
21
+ from wxcli.migration.store import MigrationStore
22
+
23
+
24
+ class JobErrorFetchFailed(Exception):
25
+ """Raised when the bulk job errors endpoint cannot be read.
26
+
27
+ Distinguishes a legitimately-empty errors list (200 response with empty
28
+ ``items``) from a fetch failure (non-200 or network error). Callers must
29
+ treat the fetch failure as "we don't know which devices failed" and
30
+ escalate to a total failure rather than silently assume zero errors.
31
+ """
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ BASE = "https://webexapis.com/v1"
36
+ MAX_RETRIES = 5
37
+
38
+ # Sentinel key: when present in a call's body dict, the engine deep-merges
39
+ # the previous call's response body into this body before sending.
40
+ # The sentinel key itself is stripped before the request is made.
41
+ # Usage: handler emits [("GET", url, None), ("PUT", url, {**overrides, "_merge_from_previous": True})]
42
+ MERGE_FROM_PREVIOUS = "_merge_from_previous"
43
+
44
+
45
+ async def poll_job_until_complete(
46
+ session: aiohttp.ClientSession,
47
+ job_type: str,
48
+ job_id: str,
49
+ poll_interval: float = 5,
50
+ max_poll_time: float = 600,
51
+ ctx: dict | None = None,
52
+ ) -> dict:
53
+ """Poll a Webex bulk device job until completion or timeout.
54
+
55
+ Returns the final job status dict. Raises TimeoutError if max_poll_time
56
+ elapses without reaching COMPLETED or FAILED.
57
+ """
58
+ from urllib.parse import urlencode
59
+
60
+ ctx = ctx or {}
61
+ path = f"/telephony/config/jobs/devices/{job_type}/{job_id}"
62
+ url = f"{BASE}{path}"
63
+ if ctx.get("orgId"):
64
+ url += f"?{urlencode({'orgId': ctx['orgId']})}"
65
+
66
+ elapsed: float = 0
67
+ while elapsed <= max_poll_time:
68
+ async with session.get(url) as resp:
69
+ body = await resp.json()
70
+
71
+ exit_code = body.get("latestExecutionExitCode", "UNKNOWN")
72
+ if exit_code in ("COMPLETED", "FAILED"):
73
+ logger.info(
74
+ "Job %s %s: exit=%s updated=%d",
75
+ job_type, job_id[:16], exit_code, body.get("updatedCount", 0),
76
+ )
77
+ return body
78
+
79
+ logger.debug(
80
+ "Job %s %s: %d%% (elapsed %.0fs)",
81
+ job_type, job_id[:16], body.get("percentageComplete", 0), elapsed,
82
+ )
83
+ if elapsed >= max_poll_time:
84
+ break
85
+ await asyncio.sleep(poll_interval)
86
+ elapsed += poll_interval
87
+
88
+ raise TimeoutError(f"Job {job_type}/{job_id} did not complete within {max_poll_time}s")
89
+
90
+
91
+ async def fetch_job_errors(
92
+ session: aiohttp.ClientSession,
93
+ job_type: str,
94
+ job_id: str,
95
+ ctx: dict | None = None,
96
+ ) -> list[dict]:
97
+ """GET the errors endpoint for a bulk job and return the items list.
98
+
99
+ GET /v1/telephony/config/jobs/devices/{jobType}/{jobId}/errors
100
+
101
+ Returns the parsed ``items`` list on a 200 response (empty list if the
102
+ payload carries no items). Raises ``JobErrorFetchFailed`` on any
103
+ non-200 status or network exception so the caller can distinguish
104
+ "no errors reported" from "we couldn't ask". Fix #6.
105
+ """
106
+ from urllib.parse import urlencode
107
+
108
+ ctx = ctx or {}
109
+ path = f"/telephony/config/jobs/devices/{job_type}/{job_id}/errors"
110
+ url = f"{BASE}{path}"
111
+ if ctx.get("orgId"):
112
+ url += f"?{urlencode({'orgId': ctx['orgId']})}"
113
+
114
+ try:
115
+ async with session.get(url) as resp:
116
+ if resp.status != 200:
117
+ raise JobErrorFetchFailed(
118
+ f"fetch_job_errors {job_id[:16]} -> HTTP {resp.status}"
119
+ )
120
+ body = await resp.json()
121
+ except JobErrorFetchFailed:
122
+ raise
123
+ except Exception as e:
124
+ raise JobErrorFetchFailed(
125
+ f"fetch_job_errors {job_id[:16]} raised: {e}"
126
+ ) from e
127
+
128
+ if not isinstance(body, dict):
129
+ return []
130
+ items = body.get("items", [])
131
+ return list(items) if isinstance(items, list) else []
132
+
133
+
134
+ @dataclass
135
+ class OpResult:
136
+ """Result of executing one operation."""
137
+ node_id: str
138
+ status: int = 0
139
+ webex_id: str | None = None
140
+ error: str | None = None
141
+ body: dict = field(default_factory=dict)
142
+
143
+ @property
144
+ def success(self) -> bool:
145
+ return 200 <= self.status < 300
146
+
147
+
148
+ def reset_in_progress(store: MigrationStore) -> int:
149
+ """Reset any in_progress ops back to pending. Returns count reset."""
150
+ cursor = store.conn.execute(
151
+ "UPDATE plan_operations SET status = 'pending' WHERE status = 'in_progress'"
152
+ )
153
+ count = cursor.rowcount
154
+ store.conn.commit()
155
+ if count:
156
+ logger.info("Reset %d in_progress operations to pending", count)
157
+ return count
158
+
159
+
160
+ async def execute_single_op(
161
+ session: aiohttp.ClientSession,
162
+ node_id: str,
163
+ calls: list[tuple[str, str, dict | None]],
164
+ semaphore: asyncio.Semaphore,
165
+ max_retries: int = MAX_RETRIES,
166
+ require_webex_id: bool = False,
167
+ ) -> OpResult:
168
+ """Execute one operation's API call(s) with rate limiting and retry.
169
+
170
+ For multi-call operations (e.g., user:configure_settings), calls are
171
+ executed sequentially. The operation fails if any sub-call fails.
172
+ The webex_id comes from the first call's response (the create).
173
+
174
+ When ``require_webex_id`` is True (Fix #18 — create ops), a success
175
+ response with no ``id`` / ``code`` in the body is treated as FAILED
176
+ instead of silently succeeding. Some backends return 200/204 without a
177
+ body on server-side hiccups — the resource may not exist and we'd have
178
+ no way to reference it in later ops.
179
+ """
180
+ webex_id = None
181
+ last_body = {}
182
+
183
+ for method, url, body in calls:
184
+ # Read-before-write: merge previous response into this body if requested
185
+ if isinstance(body, dict) and body.pop(MERGE_FROM_PREVIOUS, None):
186
+ body = {**last_body, **body}
187
+
188
+ for attempt in range(max_retries):
189
+ try:
190
+ async with semaphore:
191
+ async with session.request(method, url, json=body) as resp:
192
+ resp_status = resp.status
193
+ try:
194
+ resp_body = await resp.json()
195
+ except Exception:
196
+ resp_body = {}
197
+ resp_headers = resp.headers
198
+
199
+ if resp_status == 429:
200
+ retry_after = int(resp_headers.get("Retry-After", 5))
201
+ logger.debug("429 on %s, retry after %ds", node_id, retry_after)
202
+ await asyncio.sleep(retry_after)
203
+ continue
204
+
205
+ if resp_status >= 400:
206
+ error_msg = resp_body.get("message") or resp_body.get("errors", str(resp_body))
207
+ return OpResult(
208
+ node_id=node_id, status=resp_status,
209
+ error=f"{resp_status}: {error_msg}", body=resp_body,
210
+ )
211
+
212
+ # Success — extract ID from first call.
213
+ # Activation-code responses (POST /devices/activationCode)
214
+ # return {"code": "...", "expiryTime": "..."} with no "id" field,
215
+ # so fall back to "code" so the activation string is persisted
216
+ # into plan_operations.webex_id for export.
217
+ if webex_id is None and isinstance(resp_body, dict):
218
+ webex_id = resp_body.get("id") or resp_body.get("code")
219
+ last_body = resp_body
220
+ break # Move to next sub-call
221
+
222
+ except aiohttp.ClientError as e:
223
+ if attempt < max_retries - 1:
224
+ await asyncio.sleep(2 ** attempt)
225
+ continue
226
+ return OpResult(
227
+ node_id=node_id, status=0,
228
+ error=f"Connection error: {e}", body={},
229
+ )
230
+ else:
231
+ return OpResult(
232
+ node_id=node_id, status=429,
233
+ error="Max retries exceeded (429)", body={},
234
+ )
235
+
236
+ # Fix #18: create ops must return a usable identifier. A silent success
237
+ # with no id means the downstream planner has no webex_id to record, and
238
+ # any dependent op will fail to resolve this create's output. Surface it
239
+ # as a FAILED op instead of silently completing.
240
+ if require_webex_id and not webex_id:
241
+ return OpResult(
242
+ node_id=node_id, status=500,
243
+ error="create succeeded but response contained no id/code",
244
+ body=last_body,
245
+ )
246
+
247
+ return OpResult(
248
+ node_id=node_id, status=200,
249
+ webex_id=webex_id, body=last_body,
250
+ )
251
+
252
+
253
+ # Webex bulk job IDs are base64-encoded URN strings (typically 60-120 chars).
254
+ # We enforce a floor of 10 characters — this catches the spec's obvious
255
+ # garbage cases ("x", "", whitespace) *and* any plausible truncation /
256
+ # off-by-one parsing bug. 10 is deliberately conservative vs. the real 60+
257
+ # length: enough to rule out any 1-9 char typo while staying well under the
258
+ # real minimum so test fixtures that use mock IDs just need to use
259
+ # realistic-looking strings (which is cheap to do).
260
+ _MIN_JOB_ID_LEN = 10
261
+
262
+
263
+ def _validate_job_id(job_id: str | None) -> None:
264
+ """Fix #8: raise ``ValueError`` for missing/malformed bulk job IDs.
265
+
266
+ Webex returns base64-encoded URN job IDs. A submit response with an
267
+ empty, None, single-char, or whitespace ``id`` field points to either an
268
+ upstream backend bug or a parsing error — polling such an ID would 404
269
+ endlessly. Fail fast with a clear error so the operator knows the submit
270
+ response was bogus and the bulk op is FAILED rather than silently stuck
271
+ mid-poll.
272
+ """
273
+ if job_id is None or not isinstance(job_id, str):
274
+ raise ValueError(f"job_id is missing or not a string: {job_id!r}")
275
+ if not job_id.strip():
276
+ raise ValueError(f"job_id is empty / whitespace: {job_id!r}")
277
+ if len(job_id) < _MIN_JOB_ID_LEN:
278
+ raise ValueError(
279
+ f"job_id {job_id!r} is implausibly short "
280
+ f"(<{_MIN_JOB_ID_LEN} chars); refusing to poll"
281
+ )
282
+
283
+
284
+ async def execute_bulk_op(
285
+ session: aiohttp.ClientSession,
286
+ node_id: str,
287
+ resource_type: str,
288
+ calls: list[tuple[str, str, dict | None]],
289
+ semaphore: asyncio.Semaphore,
290
+ poll_interval: float = 5,
291
+ max_poll_time: float = 600,
292
+ ctx: dict | None = None,
293
+ fallback_context: dict | None = None,
294
+ ) -> OpResult:
295
+ """Submit a Webex bulk job, poll to completion, return an OpResult.
296
+
297
+ Expects exactly one call in ``calls`` — the submit POST. The response
298
+ body's ``id`` is the job ID. After submission, this function polls
299
+ using ``poll_job_until_complete`` until the job reaches COMPLETED or
300
+ FAILED (or times out). If the job completes but ``updatedCount`` is
301
+ less than the number of covered devices and ``fallback_context`` is
302
+ provided, the function fetches the job errors endpoint, identifies
303
+ failed device IDs, and re-runs a per-device handler for each.
304
+ """
305
+ from wxcli.migration.execute import BULK_JOB_TYPES
306
+
307
+ if len(calls) != 1:
308
+ return OpResult(
309
+ node_id=node_id, status=0,
310
+ error=f"execute_bulk_op expects exactly 1 call, got {len(calls)}",
311
+ body={},
312
+ )
313
+
314
+ method, url, body = calls[0]
315
+ job_type = BULK_JOB_TYPES.get(resource_type)
316
+ if not job_type:
317
+ return OpResult(
318
+ node_id=node_id, status=0,
319
+ error=f"No BULK_JOB_TYPES mapping for {resource_type}",
320
+ body={},
321
+ )
322
+
323
+ # Submit the job
324
+ try:
325
+ async with semaphore:
326
+ async with session.request(method, url, json=body) as resp:
327
+ submit_status = resp.status
328
+ submit_body = await resp.json()
329
+ except aiohttp.ClientError as e:
330
+ return OpResult(node_id=node_id, status=0, error=f"Submit error: {e}", body={})
331
+
332
+ if submit_status >= 400:
333
+ msg = submit_body.get("message") if isinstance(submit_body, dict) else str(submit_body)
334
+ return OpResult(
335
+ node_id=node_id, status=submit_status,
336
+ error=f"{submit_status}: {msg}", body=submit_body or {},
337
+ )
338
+
339
+ job_id = submit_body.get("id") if isinstance(submit_body, dict) else None
340
+ if not job_id:
341
+ return OpResult(
342
+ node_id=node_id, status=submit_status,
343
+ error="Submit succeeded but returned no job id",
344
+ body=submit_body or {},
345
+ )
346
+
347
+ # Fix #8: validate the job ID *before* polling. A non-empty but malformed
348
+ # id (e.g. truncated, single-char) would otherwise pass the `if not job_id`
349
+ # check above and then fail mid-poll with a confusing 404. Surface as a
350
+ # FAILED OpResult with the offending value attached for diagnostics.
351
+ try:
352
+ _validate_job_id(job_id)
353
+ except ValueError as e:
354
+ return OpResult(
355
+ node_id=node_id, status=500,
356
+ error=f"Bulk submit returned malformed job_id: {e}",
357
+ body=submit_body or {},
358
+ )
359
+
360
+ # Poll to completion
361
+ try:
362
+ final = await poll_job_until_complete(
363
+ session, job_type, job_id,
364
+ poll_interval=poll_interval,
365
+ max_poll_time=max_poll_time,
366
+ ctx=ctx,
367
+ )
368
+ except TimeoutError as e:
369
+ return OpResult(node_id=node_id, status=0, error=str(e), body={"id": job_id})
370
+
371
+ exit_code = final.get("latestExecutionExitCode", "UNKNOWN")
372
+ if exit_code != "COMPLETED":
373
+ return OpResult(
374
+ node_id=node_id, status=500,
375
+ error=f"Job {job_id} ended in {exit_code}",
376
+ body=final,
377
+ )
378
+
379
+ # Partial-failure check.
380
+ updated = final.get("updatedCount", 0)
381
+ expected = 0
382
+ excluded_unresolved = 0
383
+ if fallback_context:
384
+ expected = len(fallback_context.get("covered_devices", []))
385
+ # Fix #5/#7 companion: devices the upstream create never produced.
386
+ # `_build_fallback_context` already logged each one at WARN.
387
+ excluded_unresolved = fallback_context.get("excluded_unresolved_count", 0)
388
+
389
+ # Fix #19: when no fallback_context is configured we used to return
390
+ # success unconditionally. That silently accepted 0-update jobs. Now
391
+ # treat (fallback_context is None AND updated == 0) as a hard failure;
392
+ # when updated > 0 we keep the legacy success path (no way to verify
393
+ # `expected` without context, but SOMETHING worked).
394
+ if fallback_context is None:
395
+ if updated == 0:
396
+ return OpResult(
397
+ node_id=node_id, status=500,
398
+ error=(
399
+ f"Bulk job {job_id}: updated 0 devices and no fallback "
400
+ "configured; cannot verify success"
401
+ ),
402
+ body=final,
403
+ )
404
+ return OpResult(
405
+ node_id=node_id, status=200, webex_id=job_id, body=final,
406
+ )
407
+
408
+ if updated >= expected:
409
+ # Fix #7: even on the all-bulk-succeeded path, log a summary that
410
+ # accounts for the silently-excluded devices so the operator never
411
+ # has to guess whether anything was dropped.
412
+ logger.info(
413
+ "Bulk job %s summary: bulk_updated=%d/%d, fallback_attempted=0, "
414
+ "fallback_recovered=0, excluded_unresolved=%d",
415
+ job_id[:16], updated, expected, excluded_unresolved,
416
+ )
417
+ return OpResult(
418
+ node_id=node_id, status=200, webex_id=job_id, body=final,
419
+ )
420
+
421
+ logger.info(
422
+ "Bulk job %s partial: %d/%d succeeded — running per-device fallback",
423
+ job_id[:16], updated, expected,
424
+ )
425
+ failed_ids = await _resolve_failed_devices(
426
+ session, job_type, job_id, fallback_context, ctx,
427
+ )
428
+ if not failed_ids:
429
+ logger.warning(
430
+ "Bulk job %s updated=%d < expected=%d but error endpoint returned nothing",
431
+ job_id[:16], updated, expected,
432
+ )
433
+ return OpResult(
434
+ node_id=node_id, status=500,
435
+ error=(
436
+ f"Bulk job {job_id} partial failure with no error items "
437
+ f"(excluded_unresolved={excluded_unresolved})"
438
+ ),
439
+ body=final,
440
+ )
441
+
442
+ fallback_ok, fallback_error = await _run_per_device_fallback(
443
+ session, failed_ids, fallback_context, semaphore,
444
+ )
445
+ # Fix #4: even on fallback_ok we must verify we actually attempted every
446
+ # missing device. If the errors endpoint returned an incomplete list,
447
+ # some devices are still unaccounted for and the op must be reported
448
+ # FAILED, not COMPLETED.
449
+ attempted = len(failed_ids)
450
+ missing = (expected - updated) - attempted
451
+ fallback_recovered = attempted if fallback_ok else 0
452
+ # Fix #7: summary log fires on EVERY exit path of the fallback branch so
453
+ # bulk_updated, fallback_attempted, fallback_recovered, and the count of
454
+ # devices excluded by `_build_fallback_context` are always visible to
455
+ # the operator. Logged before the early-return so we capture it on
456
+ # both COMPLETED and FAILED outcomes.
457
+ logger.info(
458
+ "Bulk job %s summary: bulk_updated=%d/%d, fallback_attempted=%d, "
459
+ "fallback_recovered=%d, excluded_unresolved=%d",
460
+ job_id[:16], updated, expected, attempted,
461
+ fallback_recovered, excluded_unresolved,
462
+ )
463
+ if fallback_ok:
464
+ if missing > 0:
465
+ logger.warning(
466
+ "Bulk job %s: fallback recovered %d but %d device(s) never attempted",
467
+ job_id[:16], attempted, missing,
468
+ )
469
+ return OpResult(
470
+ node_id=node_id, status=500,
471
+ error=(
472
+ f"Bulk {job_id}: partial failure — {updated} succeeded in bulk, "
473
+ f"{attempted} recovered via fallback, but {missing} device(s) "
474
+ f"still unaccounted for "
475
+ f"(excluded_unresolved={excluded_unresolved})"
476
+ ),
477
+ body=final,
478
+ )
479
+ logger.info("Bulk job %s: fallback recovered all %d failed devices",
480
+ job_id[:16], attempted)
481
+ return OpResult(
482
+ node_id=node_id, status=200, webex_id=job_id, body=final,
483
+ )
484
+ return OpResult(
485
+ node_id=node_id, status=500,
486
+ error=(
487
+ f"Bulk {job_id}: fallback failed — {fallback_error} "
488
+ f"(excluded_unresolved={excluded_unresolved})"
489
+ ),
490
+ body=final,
491
+ )
492
+
493
+
494
+ async def _resolve_failed_devices(
495
+ session: aiohttp.ClientSession,
496
+ job_type: str,
497
+ job_id: str,
498
+ fallback_context: dict,
499
+ ctx: dict | None,
500
+ ) -> list[str]:
501
+ """Return the list of webex device IDs that failed in the bulk job.
502
+
503
+ Reads the errors endpoint and pulls itemId for each failed row. If the
504
+ errors endpoint itself can't be read (HTTP non-200 or network failure),
505
+ returns ``[]`` and logs a WARNING. ``execute_bulk_op`` already treats
506
+ an empty ``failed_ids`` list as a total failure, so this preserves the
507
+ safe behavior. Fix #6.
508
+ """
509
+ try:
510
+ items = await fetch_job_errors(session, job_type, job_id, ctx)
511
+ except JobErrorFetchFailed:
512
+ logger.warning(
513
+ "Cannot fetch job errors for %s — treating partial bulk failure as total failure",
514
+ job_id[:16],
515
+ )
516
+ return []
517
+ return [i["itemId"] for i in items if i.get("itemId")]
518
+
519
+
520
+ async def _run_per_device_fallback(
521
+ session: aiohttp.ClientSession,
522
+ failed_webex_ids: list[str],
523
+ fallback_context: dict,
524
+ semaphore: asyncio.Semaphore,
525
+ ) -> tuple[bool, str | None]:
526
+ """Re-run the per-device handler for each failed device.
527
+
528
+ Returns (all_ok, error_message). The handler is looked up in
529
+ HANDLER_REGISTRY by fallback_context['fallback_handler_key']. Deps
530
+ are augmented with the device's own canonical_id → webex_id so the
531
+ handler can resolve the device. If the handler still returns no calls
532
+ (e.g. because the settings dict is empty but present), a direct PUT
533
+ to the device settings endpoint is issued using the record's data.
534
+
535
+ Fix #7: aggregate errors instead of fail-fast. A single bad device
536
+ should not mask the fate of the rest — we collect unresolved and
537
+ per-device failures, log a WARNING summary after the loop, and
538
+ return a compacted error string listing the first few failures plus
539
+ totals. Fix #8: handler may return ``SkippedResult`` — treat that as
540
+ a per-device failure (not a bare TypeError on iteration).
541
+ """
542
+ handler_key = fallback_context.get("fallback_handler_key")
543
+ handler = HANDLER_REGISTRY.get(handler_key) if handler_key else None
544
+ if handler is None:
545
+ return False, f"No handler for fallback key {handler_key}"
546
+
547
+ covered = {d["webex_id"]: d for d in fallback_context.get("covered_devices", [])}
548
+ deps = fallback_context.get("deps", {})
549
+ ctx = fallback_context.get("ctx", {})
550
+
551
+ unresolved: list[str] = []
552
+ per_device_errors: list[tuple[str, str]] = []
553
+
554
+ for wid in failed_webex_ids:
555
+ record = covered.get(wid)
556
+ if not record:
557
+ unresolved.append(wid)
558
+ continue
559
+ # Augment deps with the device's own webex_id so handler can resolve it.
560
+ per_device_deps = {**deps, record["canonical_id"]: record["webex_id"]}
561
+ calls = handler(record.get("data", {}), per_device_deps, ctx)
562
+ # Fix #8: a handler that returns SkippedResult is truthy but not
563
+ # iterable — unwrap it as a per-device failure with the reason so
564
+ # the `for method, url, body in calls` loop never sees it.
565
+ if isinstance(calls, SkippedResult):
566
+ per_device_errors.append((wid, f"skipped: {calls.reason}"))
567
+ continue
568
+ if not calls:
569
+ # Handler returned no-op. Issue a direct settings PUT using the
570
+ # record's data (supports both "device_settings" and "settings" keys).
571
+ data_body = record.get("data", {})
572
+ settings = data_body.get("device_settings")
573
+ if settings is None:
574
+ settings = data_body.get("settings")
575
+ if settings is None:
576
+ continue # truly nothing to apply → treat as success
577
+ url = f"{BASE}/telephony/config/devices/{wid}/settings"
578
+ calls = [("PUT", url, settings)]
579
+ call_failed = False
580
+ for method, url, body in calls:
581
+ async with semaphore:
582
+ async with session.request(method, url, json=body) as resp:
583
+ if resp.status >= 400:
584
+ text = await resp.text()
585
+ per_device_errors.append(
586
+ (wid, f"fallback {resp.status}: {text[:200]}")
587
+ )
588
+ call_failed = True
589
+ break
590
+ if call_failed:
591
+ continue
592
+
593
+ # Fix #7: emit a single WARNING summary after the loop so the operator
594
+ # sees every unresolved / failed device in one line, not one exception
595
+ # per case. Only fires when there's something to report.
596
+ if unresolved or per_device_errors:
597
+ logger.warning(
598
+ "Fallback summary: %d unresolved devices %s, %d per-device failures",
599
+ len(unresolved), unresolved, len(per_device_errors),
600
+ )
601
+
602
+ if not unresolved and not per_device_errors:
603
+ return True, None
604
+ # Compact error: list the first 3 per-device errors plus totals for the
605
+ # operator-facing OpResult.error. Unresolved count makes it in too.
606
+ head = per_device_errors[:3]
607
+ return False, (
608
+ f"unresolved={len(unresolved)}, failed={len(per_device_errors)}: {head}"
609
+ )
610
+
611
+
612
+ async def _try_find_existing(
613
+ session: aiohttp.ClientSession,
614
+ semaphore: asyncio.Semaphore,
615
+ resource_type: str,
616
+ data: dict,
617
+ ctx: dict,
618
+ ) -> str | None:
619
+ """On 409, search for the existing resource and return its Webex ID."""
620
+ from urllib.parse import urlencode
621
+ search_url = None
622
+ item_key = None
623
+
624
+ if resource_type == "user":
625
+ email = (data.get("emails") or [""])[0]
626
+ if email:
627
+ params = {"email": email}
628
+ if ctx.get("orgId"):
629
+ params["orgId"] = ctx["orgId"]
630
+ search_url = f"{BASE}/people?{urlencode(params)}"
631
+ item_key = "items"
632
+ elif resource_type == "location":
633
+ name = data.get("name")
634
+ if name:
635
+ params = {"name": name}
636
+ if ctx.get("orgId"):
637
+ params["orgId"] = ctx["orgId"]
638
+ search_url = f"{BASE}/locations?{urlencode(params)}"
639
+ item_key = "items"
640
+ elif resource_type == "translation_pattern":
641
+ name = data.get("name")
642
+ if name:
643
+ params = {"name": name, "max": "100"}
644
+ if ctx.get("orgId"):
645
+ params["orgId"] = ctx["orgId"]
646
+ search_url = f"{BASE}/telephony/config/callRouting/translationPatterns?{urlencode(params)}"
647
+ item_key = "translationPatterns"
648
+ elif resource_type == "trunk":
649
+ name = data.get("name")
650
+ if name:
651
+ params = {"name": name, "max": "100"}
652
+ if ctx.get("orgId"):
653
+ params["orgId"] = ctx["orgId"]
654
+ search_url = f"{BASE}/telephony/config/premisePstn/trunks?{urlencode(params)}"
655
+ item_key = "trunks"
656
+ elif resource_type == "dial_plan":
657
+ name = data.get("name")
658
+ if name:
659
+ params = {"name": name, "max": "100"}
660
+ if ctx.get("orgId"):
661
+ params["orgId"] = ctx["orgId"]
662
+ search_url = f"{BASE}/telephony/config/premisePstn/dialPlans?{urlencode(params)}"
663
+ item_key = "dialPlans"
664
+ elif resource_type == "operating_mode":
665
+ name = data.get("name")
666
+ if name:
667
+ params = {"name": name, "max": "100"}
668
+ if ctx.get("orgId"):
669
+ params["orgId"] = ctx["orgId"]
670
+ search_url = f"{BASE}/telephony/config/operatingModes?{urlencode(params)}"
671
+ item_key = "operatingModes"
672
+ elif resource_type == "schedule":
673
+ name = data.get("name")
674
+ location_id = data.get("location_id") or data.get("locationId", "")
675
+ if name and location_id:
676
+ params = {"name": name, "max": "100"}
677
+ if ctx.get("orgId"):
678
+ params["orgId"] = ctx["orgId"]
679
+ search_url = f"{BASE}/telephony/config/locations/{location_id}/schedules?{urlencode(params)}"
680
+ item_key = "schedules"
681
+ elif resource_type == "line_key_template":
682
+ name = data.get("name")
683
+ if name:
684
+ params = {"name": name, "max": "100"}
685
+ if ctx.get("orgId"):
686
+ params["orgId"] = ctx["orgId"]
687
+ search_url = f"{BASE}/telephony/config/devices/lineKeyTemplates?{urlencode(params)}"
688
+ item_key = "lineKeyTemplates"
689
+ elif resource_type in ("call_park", "pickup_group", "paging_group",
690
+ "hunt_group", "call_queue", "auto_attendant"):
691
+ # Location-scoped features — need location_id from deps
692
+ # These are harder to 409-recover because they need a location search.
693
+ # Skip recovery — let cascade handle it on next run.
694
+ pass
695
+
696
+ # For types without search support, return None
697
+ if not search_url:
698
+ return None
699
+
700
+ try:
701
+ async with semaphore:
702
+ async with session.get(search_url) as resp:
703
+ if resp.status == 200:
704
+ body = await resp.json()
705
+ items = body.get(item_key, []) if isinstance(body, dict) else body
706
+ if items and len(items) > 0:
707
+ return items[0].get("id")
708
+ except Exception:
709
+ pass
710
+ return None
711
+
712
+
713
+ def _build_fallback_context(op: dict, ctx: dict) -> dict | None:
714
+ """Build fallback_context for a bulk op from its payload and resolved deps.
715
+
716
+ Returns None if the op's payload lacks ``covered_canonical_ids`` or
717
+ ``fallback_handler_key`` — e.g. for ``bulk_rebuild_phones`` which has
718
+ no per-device fallback path.
719
+
720
+ Fix #5: when a covered_cid has no resolved webex_id (because its upstream
721
+ create op failed/skipped or hasn't run yet), the device cannot participate
722
+ in either the bulk submit or the per-device fallback. We log a WARNING
723
+ per-cid and surface the count via ``excluded_unresolved_count`` so callers
724
+ (and the operator-facing summary log in ``execute_bulk_op``) can account
725
+ for every device that was silently dropped from the fallback path.
726
+ """
727
+ data = op.get("data", {})
728
+ covered_cids = data.get("covered_canonical_ids")
729
+ fallback_key = data.get("fallback_handler_key")
730
+ if not covered_cids or not fallback_key:
731
+ return None
732
+
733
+ resolved_deps = op.get("resolved_deps", {})
734
+ store = ctx.get("store")
735
+
736
+ covered_devices = []
737
+ excluded: list[str] = []
738
+ for cid in covered_cids:
739
+ webex_id = resolved_deps.get(cid)
740
+ if not webex_id:
741
+ # Fix #5: surface the silent drop so it lands in the logs and in
742
+ # the returned context, instead of a bare `continue`.
743
+ logger.warning(
744
+ "Bulk fallback: device %s not yet created, excluding from fallback",
745
+ cid,
746
+ )
747
+ excluded.append(cid)
748
+ continue
749
+ device_data = {}
750
+ if store:
751
+ obj = store.get_object(cid)
752
+ if obj:
753
+ device_data = obj if isinstance(obj, dict) else obj
754
+ covered_devices.append({
755
+ "canonical_id": cid,
756
+ "webex_id": webex_id,
757
+ "data": device_data,
758
+ })
759
+
760
+ return {
761
+ "fallback_handler_key": tuple(fallback_key),
762
+ "covered_devices": covered_devices,
763
+ "deps": resolved_deps,
764
+ "ctx": ctx,
765
+ "excluded_unresolved_count": len(excluded),
766
+ "excluded_canonical_ids": excluded,
767
+ }
768
+
769
+
770
+ async def run_batch_ops(
771
+ session: aiohttp.ClientSession,
772
+ tasks: list[dict],
773
+ semaphore: asyncio.Semaphore,
774
+ ctx: dict,
775
+ poll_interval: float = 5,
776
+ max_poll_time: float = 600,
777
+ ) -> list[OpResult]:
778
+ """Execute all tasks in one (batch, tier) group.
779
+
780
+ Each task is ``{"op": {...}, "calls": [(method, url, body), ...]}``.
781
+ Tasks whose op.resource_type is in ``SERIALIZED_RESOURCE_TYPES`` run
782
+ sequentially; all others run concurrently via ``asyncio.gather``.
783
+ Bulk ops dispatch to ``execute_bulk_op``; non-bulk ops use
784
+ ``execute_single_op``.
785
+
786
+ Results are returned in the same order as the input tasks list so
787
+ that callers can zip(tasks, results) without misalignment.
788
+ """
789
+ from wxcli.migration.execute import SERIALIZED_RESOURCE_TYPES
790
+
791
+ results: list[OpResult | None] = [None] * len(tasks)
792
+
793
+ parallel_indexed: list[tuple[int, dict]] = []
794
+ serial_indexed: list[tuple[int, dict]] = []
795
+ for idx, t in enumerate(tasks):
796
+ op = t["op"]
797
+ if op["resource_type"] in SERIALIZED_RESOURCE_TYPES:
798
+ serial_indexed.append((idx, t))
799
+ else:
800
+ parallel_indexed.append((idx, t))
801
+
802
+ # Kick off parallel ops
803
+ if parallel_indexed:
804
+ parallel_coros = [
805
+ execute_single_op(
806
+ session,
807
+ t["op"]["node_id"],
808
+ t["calls"],
809
+ semaphore,
810
+ # Fix #18: require a returned id/code for create ops so a
811
+ # silent "200 OK with empty body" cannot masquerade as a
812
+ # successful resource creation. Fix #3: also gate
813
+ # ``create_activation_code`` ops — convertible phones use
814
+ # this op_type and fall back to the response's ``code``
815
+ # field as the webex_id (see execute_single_op).
816
+ require_webex_id=(
817
+ t["op"].get("op_type") in ("create", "create_activation_code")
818
+ ),
819
+ )
820
+ for _, t in parallel_indexed
821
+ ]
822
+ parallel_results = await asyncio.gather(*parallel_coros, return_exceptions=True)
823
+ for (idx, t), res in zip(parallel_indexed, parallel_results):
824
+ if isinstance(res, Exception):
825
+ results[idx] = OpResult(
826
+ node_id=t["op"]["node_id"], status=0, error=str(res), body={},
827
+ )
828
+ else:
829
+ results[idx] = res
830
+
831
+ # Then serial bulk ops, one at a time
832
+ for idx, t in serial_indexed:
833
+ op = t["op"]
834
+ fallback_ctx = _build_fallback_context(op, ctx)
835
+ try:
836
+ res = await execute_bulk_op(
837
+ session,
838
+ node_id=op["node_id"],
839
+ resource_type=op["resource_type"],
840
+ calls=t["calls"],
841
+ semaphore=semaphore,
842
+ poll_interval=poll_interval,
843
+ max_poll_time=max_poll_time,
844
+ ctx=ctx,
845
+ fallback_context=fallback_ctx,
846
+ )
847
+ except Exception as e:
848
+ res = OpResult(node_id=op["node_id"], status=0, error=str(e), body={})
849
+ results[idx] = res
850
+
851
+ # At this point every slot should be filled; cast for the return type
852
+ return [r for r in results if r is not None] # type: ignore[misc]
853
+
854
+
855
+ async def execute_all_batches(
856
+ store: MigrationStore,
857
+ token: str,
858
+ concurrency: int = 20,
859
+ ctx: dict | None = None,
860
+ on_progress: Any = None,
861
+ ) -> dict[str, int]:
862
+ """Execute all pending operations in batch order.
863
+
864
+ Returns summary: {"completed": N, "failed": M, "skipped": K}
865
+ """
866
+ ctx = ctx or {}
867
+ ctx.setdefault("store", store)
868
+ summary = {"completed": 0, "failed": 0, "skipped": 0, "batches": 0}
869
+ semaphore = asyncio.Semaphore(concurrency)
870
+ headers = {
871
+ "Authorization": f"Bearer {token}",
872
+ "Content-Type": "application/json",
873
+ }
874
+
875
+ async with aiohttp.ClientSession(headers=headers) as session:
876
+ while True:
877
+ batch = get_next_batch(store)
878
+ if not batch:
879
+ break
880
+
881
+ summary["batches"] += 1
882
+ batch_name = batch[0]["batch"] or "org-wide"
883
+ tier = batch[0]["tier"]
884
+ logger.info("Batch %s / tier %d: %d operations",
885
+ batch_name, tier, len(batch))
886
+ if on_progress:
887
+ on_progress(f"Batch: {batch_name} / tier {tier} ({len(batch)} ops)")
888
+
889
+ # Build tasks for this batch
890
+ tasks = []
891
+ for op in batch:
892
+ handler = HANDLER_REGISTRY.get(
893
+ (op["resource_type"], op["op_type"])
894
+ )
895
+ if not handler:
896
+ update_op_status(store, op["node_id"], "failed",
897
+ error_message=f"No handler for {op['resource_type']}:{op['op_type']}")
898
+ summary["failed"] += 1
899
+ continue
900
+
901
+ calls = handler(op["data"], op["resolved_deps"], ctx)
902
+ if isinstance(calls, SkippedResult):
903
+ # Handler declared a hard-prerequisite miss — record as
904
+ # 'skipped' so the report separates these from FAILED ops
905
+ # and cascades skip to dependents just like a failure.
906
+ update_op_status(
907
+ store, op["node_id"], "skipped",
908
+ error_message=calls.reason,
909
+ )
910
+ summary["skipped"] += 1
911
+ continue
912
+ if not calls:
913
+ # No-op (e.g., empty workspace assign_number)
914
+ update_op_status(store, op["node_id"], "completed")
915
+ summary["completed"] += 1
916
+ continue
917
+
918
+ tasks.append((op, calls))
919
+
920
+ # Execute all ops in this batch — serialized bulk types run sequentially.
921
+ if tasks:
922
+ results = await run_batch_ops(
923
+ session, [{"op": op, "calls": calls} for op, calls in tasks],
924
+ semaphore=semaphore, ctx=ctx,
925
+ )
926
+
927
+ for (op, _calls), result in zip(tasks, results):
928
+ op_status = "failed" # default
929
+
930
+ if isinstance(result, Exception):
931
+ update_op_status(store, op["node_id"], "failed",
932
+ error_message=str(result))
933
+ summary["failed"] += 1
934
+ elif result.success:
935
+ update_op_status(store, op["node_id"], "completed",
936
+ webex_id=result.webex_id)
937
+ summary["completed"] += 1
938
+ op_status = "completed"
939
+ elif result.status == 409:
940
+ # Auto-recover: search for existing resource
941
+ existing_id = await _try_find_existing(
942
+ session, semaphore,
943
+ op["resource_type"], op["data"], ctx,
944
+ )
945
+ if existing_id:
946
+ update_op_status(store, op["node_id"], "completed",
947
+ webex_id=existing_id)
948
+ summary["completed"] += 1
949
+ op_status = "completed"
950
+ logger.info("409 auto-recovered %s -> %s",
951
+ op["node_id"], existing_id)
952
+ else:
953
+ update_op_status(store, op["node_id"], "failed",
954
+ error_message=result.error)
955
+ summary["failed"] += 1
956
+ else:
957
+ update_op_status(store, op["node_id"], "failed",
958
+ error_message=result.error)
959
+ summary["failed"] += 1
960
+
961
+ if on_progress:
962
+ on_progress(f" {op['node_id']}: {op_status}")
963
+
964
+ return summary