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,1427 @@
1
+ """Batch-delete Webex Calling resources in dependency-safe order."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from dataclasses import dataclass, field
8
+
9
+ import click
10
+ import typer
11
+ from rich.console import Console
12
+ from wxcli.errors import WebexError
13
+
14
+ # Location-delete retry tuning: after the initial 90s calling-detach
15
+ # propagation wait, 17-location orgs have been observed to still 409 on
16
+ # first attempt. 5 attempts × 60s = 5 min worst case per laggard location,
17
+ # which fits under typical agent/bash tool timeouts and covers the trailing
18
+ # edge of async calling-config propagation without masking real failures.
19
+ LOCATION_DELETE_MAX_ATTEMPTS = 5
20
+ LOCATION_DELETE_RETRY_SLEEP = 60
21
+
22
+ # Signature of the CCP-integrated PSTN backend gate. When a delete fails with
23
+ # this error code, the org uses Cisco Calling Plan and the resource is managed
24
+ # by the Webex backend — no API action on our side can unblock it.
25
+ CCP_INTEGRATED_ERROR_CODE = "ERR.V.TRM.TMN60004"
26
+
27
+ from wxcli.auth import get_api
28
+ from wxcli.config import get_org_id
29
+
30
+ logger = logging.getLogger(__name__)
31
+ console = Console()
32
+
33
+ BASE = "https://webexapis.com/v1"
34
+
35
+ app = typer.Typer(
36
+ name="cleanup",
37
+ help="Batch-delete Webex Calling resources in dependency-safe order.",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class ResourceType:
44
+ """Definition of a Webex resource type for cleanup."""
45
+ name: str
46
+ list_url: str
47
+ item_key: str
48
+ delete_url: str
49
+ location_scoped_list: bool = False
50
+ supports_location_filter: bool = False
51
+ server_side_location_filter: bool = False
52
+ id_field: str = "id"
53
+ name_field: str = "name"
54
+ extra_delete_fields: list[str] = field(default_factory=list)
55
+ # Extra query params always included when listing this resource type.
56
+ # Used for /people which requires callingData=true for locationId filtering.
57
+ extra_list_params: dict = field(default_factory=dict)
58
+
59
+
60
+ RESOURCE_TYPES: dict[str, ResourceType] = {
61
+ "dial_plans": ResourceType(
62
+ name="Dial Plans",
63
+ list_url="/telephony/config/premisePstn/dialPlans",
64
+ item_key="dialPlans",
65
+ delete_url="/telephony/config/premisePstn/dialPlans/{id}",
66
+ ),
67
+ "route_lists": ResourceType(
68
+ name="Route Lists",
69
+ list_url="/telephony/config/premisePstn/routeLists",
70
+ item_key="routeLists",
71
+ delete_url="/telephony/config/premisePstn/routeLists/{id}",
72
+ ),
73
+ "route_groups": ResourceType(
74
+ name="Route Groups",
75
+ list_url="/telephony/config/premisePstn/routeGroups",
76
+ item_key="routeGroups",
77
+ delete_url="/telephony/config/premisePstn/routeGroups/{id}",
78
+ ),
79
+ "translation_patterns": ResourceType(
80
+ name="Translation Patterns",
81
+ list_url="/telephony/config/callRouting/translationPatterns",
82
+ item_key="translationPatterns",
83
+ delete_url="/telephony/config/callRouting/translationPatterns/{id}",
84
+ ),
85
+ "trunks": ResourceType(
86
+ name="Trunks",
87
+ list_url="/telephony/config/premisePstn/trunks",
88
+ item_key="trunks",
89
+ delete_url="/telephony/config/premisePstn/trunks/{id}",
90
+ ),
91
+ "hunt_groups": ResourceType(
92
+ name="Hunt Groups",
93
+ list_url="/telephony/config/huntGroups",
94
+ item_key="huntGroups",
95
+ delete_url="/telephony/config/locations/{location_id}/huntGroups/{id}",
96
+ supports_location_filter=True,
97
+ ),
98
+ "auto_attendants": ResourceType(
99
+ name="Auto Attendants",
100
+ list_url="/telephony/config/autoAttendants",
101
+ item_key="autoAttendants",
102
+ delete_url="/telephony/config/locations/{location_id}/autoAttendants/{id}",
103
+ supports_location_filter=True,
104
+ ),
105
+ "call_queues": ResourceType(
106
+ name="Call Queues",
107
+ list_url="/telephony/config/queues",
108
+ item_key="queues",
109
+ delete_url="/telephony/config/locations/{location_id}/queues/{id}",
110
+ supports_location_filter=True,
111
+ ),
112
+ "paging_groups": ResourceType(
113
+ name="Paging Groups",
114
+ list_url="/telephony/config/paging",
115
+ item_key="locationPaging",
116
+ delete_url="/telephony/config/locations/{location_id}/paging/{id}",
117
+ supports_location_filter=True,
118
+ ),
119
+ "call_parks": ResourceType(
120
+ name="Call Parks",
121
+ list_url="/telephony/config/locations/{location_id}/callParks",
122
+ item_key="callParks",
123
+ delete_url="/telephony/config/locations/{location_id}/callParks/{id}",
124
+ location_scoped_list=True,
125
+ ),
126
+ "call_pickups": ResourceType(
127
+ name="Call Pickups",
128
+ list_url="/telephony/config/locations/{location_id}/callPickups",
129
+ item_key="callPickups",
130
+ delete_url="/telephony/config/locations/{location_id}/callPickups/{id}",
131
+ location_scoped_list=True,
132
+ ),
133
+ "operating_modes": ResourceType(
134
+ name="Operating Modes",
135
+ list_url="/telephony/config/operatingModes",
136
+ item_key="operatingModes",
137
+ delete_url="/telephony/config/operatingModes/{id}",
138
+ ),
139
+ "schedules": ResourceType(
140
+ name="Schedules",
141
+ list_url="/telephony/config/locations/{location_id}/schedules",
142
+ item_key="schedules",
143
+ delete_url="/telephony/config/locations/{location_id}/schedules/{type}/{id}",
144
+ location_scoped_list=True,
145
+ extra_delete_fields=["type"],
146
+ ),
147
+ "virtual_lines": ResourceType(
148
+ name="Virtual Lines",
149
+ list_url="/telephony/config/virtualLines",
150
+ item_key="virtualLines",
151
+ delete_url="/telephony/config/virtualLines/{id}",
152
+ ),
153
+ "devices": ResourceType(
154
+ name="Devices",
155
+ list_url="/devices",
156
+ item_key="items",
157
+ delete_url="/devices/{id}",
158
+ ),
159
+ "workspaces": ResourceType(
160
+ name="Workspaces",
161
+ list_url="/workspaces",
162
+ item_key="items",
163
+ delete_url="/workspaces/{id}",
164
+ supports_location_filter=True,
165
+ ),
166
+ "users": ResourceType(
167
+ name="Users",
168
+ list_url="/people",
169
+ item_key="items",
170
+ delete_url="/people/{id}",
171
+ name_field="displayName",
172
+ server_side_location_filter=True,
173
+ # /people?locationId=... requires callingData=true or the API returns
174
+ # "Calling flag not set" (error 25008 equivalent). Without this, scoped
175
+ # user listing silently returns nothing and users are never cleaned up.
176
+ extra_list_params={"callingData": "true"},
177
+ ),
178
+ "numbers": ResourceType(
179
+ name="Numbers",
180
+ list_url="/telephony/config/numbers",
181
+ item_key="phoneNumbers",
182
+ delete_url="/telephony/config/locations/{location_id}/numbers",
183
+ ),
184
+ "locations": ResourceType(
185
+ name="Locations",
186
+ list_url="/locations",
187
+ item_key="items",
188
+ delete_url="/locations/{id}",
189
+ ),
190
+ }
191
+
192
+ # Deletion proceeds in this order (reverse of creation).
193
+ # Within each layer, all deletions run in parallel.
194
+ DELETION_LAYERS: list[list[str]] = [
195
+ ["dial_plans"], # Layer 1: reference route lists/groups
196
+ ["route_lists"], # Layer 2: reference route groups
197
+ ["route_groups"], # Layer 3: reference trunks
198
+ ["translation_patterns"], # Layer 4: org-level or location-level
199
+ ["trunks"], # Layer 5: reference locations
200
+ ["call_queues", "hunt_groups", "auto_attendants", "paging_groups", "call_parks", "call_pickups"], # Layer 6
201
+ ["operating_modes", "schedules"], # Layer 7: were referenced by AAs
202
+ ["virtual_lines"], # Layer 8
203
+ ["devices"], # Layer 9
204
+ ["workspaces"], # Layer 10: must delete before location detach attempt
205
+ ["users"], # Layer 11: opt-in via --include-users
206
+ ["numbers"], # Layer 12: remove numbers before location deletion
207
+ ["locations"], # Layer 13: attempt calling detach first, then delete
208
+ ]
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Inventory
213
+ # ---------------------------------------------------------------------------
214
+
215
+ def list_resources(
216
+ api,
217
+ rt: ResourceType,
218
+ org_id: str | None,
219
+ location_ids: list[str] | None,
220
+ scope_filter: bool = True,
221
+ ) -> list[dict]:
222
+ """List all resources of a given type, respecting scope.
223
+
224
+ Args:
225
+ scope_filter: When True, filter supports_location_filter resources by
226
+ location_ids. Set False for org-wide cleanup so resources without
227
+ a locationId are not silently excluded.
228
+ """
229
+ params = {}
230
+ if org_id:
231
+ params["orgId"] = org_id
232
+ if rt.extra_list_params:
233
+ params.update(rt.extra_list_params)
234
+
235
+ if rt.location_scoped_list:
236
+ # Must iterate per location
237
+ if not location_ids:
238
+ return []
239
+ all_items = []
240
+ for loc_id in location_ids:
241
+ url = f"{BASE}{rt.list_url.format(location_id=loc_id)}"
242
+ try:
243
+ items = list(api.session.follow_pagination(
244
+ url=url, params=dict(params), item_key=rt.item_key,
245
+ ))
246
+ # Tag each item with its location_id for delete
247
+ for item in items:
248
+ item.setdefault("locationId", loc_id)
249
+ all_items.extend(items)
250
+ except WebexError as e:
251
+ logger.warning("Failed to list %s in location %s: %s", rt.name, loc_id, e)
252
+ console.print(f"[yellow]⚠ Failed to list {rt.name} in location {loc_id}: {e} — inventory may be incomplete[/yellow]")
253
+ return all_items
254
+
255
+ if scope_filter and location_ids and rt.server_side_location_filter:
256
+ all_items = []
257
+ for loc_id in location_ids:
258
+ try:
259
+ loc_params = {**params, "locationId": loc_id}
260
+ items = list(api.session.follow_pagination(
261
+ url=f"{BASE}{rt.list_url}", params=loc_params,
262
+ item_key=rt.item_key,
263
+ ))
264
+ for item in items:
265
+ item.setdefault("locationId", loc_id)
266
+ all_items.extend(items)
267
+ except WebexError as e:
268
+ logger.warning("Failed to list %s in location %s: %s", rt.name, loc_id, e)
269
+ console.print(f"[yellow]⚠ Failed to list {rt.name} in location {loc_id}: {e} — inventory may be incomplete[/yellow]")
270
+ return all_items
271
+
272
+ # Org-scoped list
273
+ url = f"{BASE}{rt.list_url}"
274
+ try:
275
+ items = list(api.session.follow_pagination(
276
+ url=url, params=params, item_key=rt.item_key,
277
+ ))
278
+ except WebexError as e:
279
+ logger.warning("Failed to list %s: %s", rt.name, e)
280
+ console.print(f"[yellow]⚠ Failed to list {rt.name}: {e} — inventory may be incomplete[/yellow]")
281
+ return []
282
+
283
+ # Filter by location scope if requested and items have locationId
284
+ if scope_filter and location_ids and rt.supports_location_filter:
285
+ loc_set = set(location_ids)
286
+ items = [i for i in items if i.get("locationId") in loc_set]
287
+
288
+ return items
289
+
290
+
291
+ def build_inventory(
292
+ api,
293
+ org_id: str | None,
294
+ location_ids: list[str] | None,
295
+ include_users: bool,
296
+ include_locations: bool,
297
+ scope_filter: bool = True,
298
+ ) -> dict[str, list[dict]]:
299
+ """Build a full inventory of resources to delete."""
300
+ inventory: dict[str, list[dict]] = {}
301
+
302
+ for layer in DELETION_LAYERS:
303
+ for key in layer:
304
+ if key == "users" and not include_users:
305
+ continue
306
+ if key == "locations" and not include_locations:
307
+ continue
308
+ if key == "numbers":
309
+ continue # numbers populated separately by build_number_inventory
310
+
311
+ rt = RESOURCE_TYPES[key]
312
+ items = list_resources(api, rt, org_id, location_ids, scope_filter=scope_filter)
313
+ if key == "locations" and scope_filter and location_ids:
314
+ loc_set = set(location_ids)
315
+ items = [i for i in items if i.get("id") in loc_set]
316
+ if items:
317
+ inventory[key] = items
318
+
319
+ return inventory
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Deletion
324
+ # ---------------------------------------------------------------------------
325
+
326
+ @dataclass
327
+ class DeleteResult:
328
+ """Result of a single delete operation."""
329
+ resource_type: str
330
+ resource_id: str
331
+ resource_name: str
332
+ success: bool
333
+ error: str | None = None
334
+ # True when a failure was classified as a CCP-integrated PSTN backend
335
+ # gate (e.g. Webex async-releasing trunk references). These aren't real
336
+ # failures the operator can act on — the final summary reports them
337
+ # separately and the process exits 0.
338
+ ccp_blocked: bool = False
339
+
340
+
341
+ def delete_resource(
342
+ api,
343
+ rt: ResourceType,
344
+ item: dict,
345
+ org_id: str | None,
346
+ ) -> DeleteResult:
347
+ """Delete a single resource. Returns a DeleteResult."""
348
+ resource_id = item.get(rt.id_field, "unknown")
349
+ resource_name = item.get(rt.name_field, "unnamed")
350
+
351
+ # Build delete URL
352
+ fmt_kwargs = {"id": resource_id}
353
+ if "{location_id}" in rt.delete_url:
354
+ fmt_kwargs["location_id"] = item.get("locationId", "")
355
+ for field_name in rt.extra_delete_fields:
356
+ fmt_kwargs[field_name] = item.get(field_name, "")
357
+
358
+ url = f"{BASE}{rt.delete_url.format(**fmt_kwargs)}"
359
+ params = {}
360
+ if org_id:
361
+ params["orgId"] = org_id
362
+
363
+ try:
364
+ api.session.rest_delete(url, params=params)
365
+ return DeleteResult(
366
+ resource_type=rt.name,
367
+ resource_id=resource_id,
368
+ resource_name=resource_name,
369
+ success=True,
370
+ )
371
+ except Exception as e:
372
+ return DeleteResult(
373
+ resource_type=rt.name,
374
+ resource_id=resource_id,
375
+ resource_name=resource_name,
376
+ success=False,
377
+ error=str(e),
378
+ )
379
+
380
+
381
+ def execute_layer(
382
+ api,
383
+ layer_keys: list[str],
384
+ inventory: dict[str, list[dict]],
385
+ org_id: str | None,
386
+ max_concurrent: int = 5,
387
+ ) -> list[DeleteResult]:
388
+ """Execute all deletions in a layer in parallel."""
389
+ # Collect all (rt, item) pairs for this layer
390
+ work: list[tuple[ResourceType, dict]] = []
391
+ for key in layer_keys:
392
+ if key not in inventory:
393
+ continue
394
+ rt = RESOURCE_TYPES[key]
395
+ for item in inventory[key]:
396
+ work.append((rt, item))
397
+
398
+ if not work:
399
+ return []
400
+
401
+ results: list[DeleteResult] = []
402
+ with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
403
+ futures = {
404
+ executor.submit(delete_resource, api, rt, item, org_id): (rt, item)
405
+ for rt, item in work
406
+ }
407
+ for future in as_completed(futures):
408
+ results.append(future.result())
409
+
410
+ return results
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # Location-specific deletion
415
+ # ---------------------------------------------------------------------------
416
+
417
+ def disable_location_calling(
418
+ api,
419
+ location_id: str,
420
+ org_id: str | None,
421
+ ) -> DeleteResult:
422
+ """Best-effort telephony detach before location deletion.
423
+
424
+ The DELETE against ``/telephony/config/locations/{id}`` is useful in
425
+ practice, but it is not a reliable public contract for disabling calling
426
+ on a location. Success here only means the request completed; location
427
+ delete can still fail on visible or backend-held dependencies.
428
+ """
429
+ url = f"{BASE}/telephony/config/locations/{location_id}"
430
+ params = {}
431
+ if org_id:
432
+ params["orgId"] = org_id
433
+ try:
434
+ api.session.rest_delete(url, params=params)
435
+ return DeleteResult(
436
+ resource_type="Location Calling",
437
+ resource_id=location_id,
438
+ resource_name=f"disable-calling:{location_id}",
439
+ success=True,
440
+ )
441
+ except WebexError as e:
442
+ return DeleteResult(
443
+ resource_type="Location Calling",
444
+ resource_id=location_id,
445
+ resource_name=f"disable-calling:{location_id}",
446
+ success=False,
447
+ error=str(e),
448
+ )
449
+
450
+
451
+ def _is_ccp_integrated_error(err: str) -> bool:
452
+ """True if an error message carries the CCP-integrated PSTN signature.
453
+
454
+ Webex returns ``ERR.V.TRM.TMN60004`` (or a variant message referencing
455
+ "non-integrated CCP") whenever an API caller tries to delete a resource
456
+ whose lifecycle is owned by the Cisco Calling Plan backend. This signature
457
+ has been observed for number deletes; it can also bleed into downstream
458
+ 409s on location delete when the backend is still releasing internal
459
+ trunk references asynchronously.
460
+ """
461
+ if not err:
462
+ return False
463
+ if CCP_INTEGRATED_ERROR_CODE in err:
464
+ return True
465
+ lowered = err.lower()
466
+ return "non-integrated ccp" in lowered
467
+
468
+
469
+ def _is_ccp_backend_gate(
470
+ err: str, local_dependencies_clear: bool,
471
+ ) -> bool:
472
+ """Classify a location delete failure as the explicit CCP backend gate.
473
+
474
+ Generic 409 "being referenced" errors are not sufficient evidence on
475
+ their own. In practice they also occur when visible location blockers
476
+ remain, such as preserved users, phone numbers, or voice portal state.
477
+ Treat only the explicit CCP signature as authoritative.
478
+ """
479
+ if not err:
480
+ return False
481
+ return _is_ccp_integrated_error(err)
482
+
483
+
484
+ def _location_blockers(
485
+ api, location_id: str, org_id: str | None,
486
+ ) -> list[str]:
487
+ """Probe for visible dependencies at a location.
488
+
489
+ Returns a human-readable list of blockers still visible on the location.
490
+ This is best-effort and intentionally biased toward surfacing concrete
491
+ local blockers rather than inferring backend state from missing evidence.
492
+ """
493
+ probe_order = [
494
+ "users",
495
+ "workspaces",
496
+ "devices",
497
+ "hunt groups",
498
+ "auto attendants",
499
+ "call queues",
500
+ "paging groups",
501
+ "call parks",
502
+ "call pickups",
503
+ "trunks",
504
+ "route groups",
505
+ "numbers",
506
+ "voice portal",
507
+ ]
508
+ probes: list[tuple[str, dict, str]] = [
509
+ ("/people", {"locationId": location_id, "callingData": "true"}, "users"),
510
+ ("/workspaces", {"locationId": location_id}, "workspaces"),
511
+ ("/devices", {"locationId": location_id}, "devices"),
512
+ ("/telephony/config/huntGroups", {"locationId": location_id}, "hunt groups"),
513
+ ("/telephony/config/autoAttendants", {"locationId": location_id}, "auto attendants"),
514
+ ("/telephony/config/queues", {"locationId": location_id}, "call queues"),
515
+ ("/telephony/config/paging", {"locationId": location_id}, "paging groups"),
516
+ (f"/telephony/config/locations/{location_id}/callParks", {}, "call parks"),
517
+ (f"/telephony/config/locations/{location_id}/callPickups", {}, "call pickups"),
518
+ ("/telephony/config/premisePstn/trunks", {"locationId": location_id}, "trunks"),
519
+ ("/telephony/config/premisePstn/routeGroups", {}, "route groups"),
520
+ ]
521
+ blocker_details: dict[str, list[str]] = {}
522
+ params_base: dict = {}
523
+ if org_id:
524
+ params_base["orgId"] = org_id
525
+
526
+ def _record(label: str, detail: str | None = None) -> None:
527
+ blocker_details.setdefault(label, [])
528
+ if detail and detail not in blocker_details[label]:
529
+ blocker_details[label].append(detail)
530
+
531
+ def _has_real_items(path: str, values: list[dict]) -> bool:
532
+ """Return True only when a list contains actual resources.
533
+
534
+ Some APIs return sentinel rows such as
535
+ ``{"errors": {"peopleListLocationId": ...}}`` instead of an empty
536
+ list when a location filter matches nothing. These should not be
537
+ interpreted as visible blockers.
538
+ """
539
+ if path.endswith("/people"):
540
+ return any(isinstance(item, dict) and item.get("id") for item in values)
541
+ return bool(values)
542
+
543
+ def _item_identifier(item: dict, prefer_email: bool = False) -> str | None:
544
+ if not isinstance(item, dict):
545
+ return None
546
+ parts: list[str] = []
547
+ if prefer_email and item.get("emails"):
548
+ email = item["emails"][0]
549
+ if isinstance(email, str) and email:
550
+ parts.append(email)
551
+ name = item.get("displayName") or item.get("name")
552
+ if isinstance(name, str) and name:
553
+ parts.append(name)
554
+ item_id = item.get("id")
555
+ if isinstance(item_id, str) and item_id:
556
+ parts.append(item_id)
557
+ return " / ".join(parts[:3]) if parts else None
558
+
559
+ def _collect_details(path: str, values: list[dict], label: str) -> None:
560
+ items = [item for item in values if isinstance(item, dict)]
561
+ if path.endswith("/people"):
562
+ real_people = [item for item in items if item.get("id")]
563
+ for person in real_people[:3]:
564
+ _record(label, _item_identifier(person, prefer_email=True))
565
+ if real_people:
566
+ _record(label)
567
+ return
568
+ if path.endswith("/routeGroups"):
569
+ matched = [
570
+ rg for rg in items
571
+ if (rg.get("location") or {}).get("id") == location_id
572
+ ]
573
+ for route_group in matched[:3]:
574
+ _record(label, _item_identifier(route_group))
575
+ if matched:
576
+ _record(label)
577
+ return
578
+ for value in items[:3]:
579
+ _record(label, _item_identifier(value))
580
+ if items:
581
+ _record(label)
582
+
583
+ for path, extra, label in probes:
584
+ params = {**params_base, **extra}
585
+ try:
586
+ result = api.session.rest_get(f"{BASE}{path}", params=params)
587
+ except Exception:
588
+ continue
589
+ if not isinstance(result, dict):
590
+ continue
591
+ for values in result.values():
592
+ if isinstance(values, list) and _has_real_items(path, values):
593
+ _collect_details(path, values, label)
594
+ break
595
+
596
+ try:
597
+ numbers = api.session.rest_get(
598
+ f"{BASE}/telephony/config/numbers",
599
+ params={**params_base, "locationId": location_id},
600
+ )
601
+ phone_numbers = numbers.get("phoneNumbers", []) if isinstance(numbers, dict) else []
602
+ if phone_numbers:
603
+ _record("numbers")
604
+ for number in phone_numbers[:5]:
605
+ number_label = (
606
+ number.get("phoneNumber")
607
+ or number.get("extension")
608
+ or number.get("esn")
609
+ or number.get("id")
610
+ )
611
+ owner = number.get("owner") or {}
612
+ owner_type = owner.get("type")
613
+ if owner_type == "VOICE_MESSAGING":
614
+ detail = f"{number_label} (voice portal)" if number_label else "voice portal number"
615
+ _record("voice portal", detail)
616
+ elif number_label:
617
+ _record("numbers", number_label)
618
+ except Exception:
619
+ pass
620
+
621
+ try:
622
+ voice_portal = api.session.rest_get(
623
+ f"{BASE}/telephony/config/locations/{location_id}/voicePortal",
624
+ params=params_base,
625
+ )
626
+ if isinstance(voice_portal, dict) and voice_portal.get("id"):
627
+ vp_name = voice_portal.get("name")
628
+ vp_id = voice_portal.get("id")
629
+ detail = " / ".join(part for part in (vp_name, vp_id) if part)
630
+ _record("voice portal", detail or None)
631
+ except Exception:
632
+ pass
633
+
634
+ def _format_details(label: str, details: list[str]) -> str:
635
+ if label == "users":
636
+ prefix = "preserved users"
637
+ else:
638
+ prefix = label
639
+ if details:
640
+ return f"{prefix} [{', '.join(details)}]"
641
+ return prefix
642
+
643
+ return [
644
+ _format_details(label, blocker_details[label])
645
+ for label in probe_order
646
+ if label in blocker_details
647
+ ]
648
+
649
+
650
+ def _location_has_local_dependencies(
651
+ api, location_id: str, org_id: str | None,
652
+ ) -> bool:
653
+ """Backward-compatible boolean wrapper over the blocker probe."""
654
+ return bool(_location_blockers(api, location_id, org_id))
655
+
656
+
657
+ def _extract_blocker(err: str) -> str | None:
658
+ """Extract a blocking resource hint from a 409 error body if the API surfaces one."""
659
+ if not err:
660
+ return None
661
+ lowered = err.lower()
662
+ for kw in (
663
+ "device", "user", "workspace", "virtual line", "trunk",
664
+ "route group", "route list", "dial plan", "hunt group",
665
+ "call queue", "auto attendant", "number",
666
+ ):
667
+ if kw in lowered:
668
+ return kw
669
+ return None
670
+
671
+
672
+ def delete_location(
673
+ api,
674
+ item: dict,
675
+ org_id: str | None,
676
+ max_attempts: int = LOCATION_DELETE_MAX_ATTEMPTS,
677
+ retry_sleep: int = LOCATION_DELETE_RETRY_SLEEP,
678
+ ) -> DeleteResult:
679
+ """Delete a location after the best-effort telephony detach attempt.
680
+
681
+ Retries up to ``max_attempts`` times with ``retry_sleep`` seconds between
682
+ attempts. The 90-second caller-side propagation wait after the detach
683
+ often isn't enough on larger orgs — the backend may still 409 for several
684
+ minutes. Each attempt emits a stdout line via ``click.echo`` (flushed)
685
+ so an observing parent process sees liveness and never has to hand-roll
686
+ an external polling loop.
687
+ """
688
+ location_id = item.get("id", "")
689
+ name = item.get("name", "unnamed")
690
+ url = f"{BASE}/locations/{location_id}"
691
+ params = {}
692
+ if org_id:
693
+ params["orgId"] = org_id
694
+
695
+ last_error: str = ""
696
+ ccp_blocked = False
697
+ for attempt in range(1, max_attempts + 1):
698
+ try:
699
+ api.session.rest_delete(url, params=params)
700
+ click.echo(f"[location={name}] deleted", nl=True)
701
+ try:
702
+ click.get_text_stream("stdout").flush()
703
+ except Exception:
704
+ logger.debug("stdout flush failed — parent may lose liveness signal")
705
+ return DeleteResult(
706
+ resource_type="Locations",
707
+ resource_id=location_id,
708
+ resource_name=name,
709
+ success=True,
710
+ )
711
+ except WebexError as e:
712
+ last_error = str(e)
713
+ is_conflict = "409" in last_error or "conflict" in last_error.lower()
714
+
715
+ # Only the explicit CCP signature is authoritative enough to
716
+ # short-circuit retries. Generic "being referenced" conflicts can
717
+ # still be caused by visible blockers we should surface normally.
718
+ if _is_ccp_backend_gate(last_error, local_dependencies_clear=False):
719
+ ccp_blocked = True
720
+ break
721
+
722
+ if attempt < max_attempts and is_conflict:
723
+ click.echo(
724
+ f"[location={name}] attempt {attempt}/{max_attempts}: "
725
+ f"409 — retrying in {retry_sleep}s",
726
+ nl=True,
727
+ )
728
+ try:
729
+ click.get_text_stream("stdout").flush()
730
+ except Exception:
731
+ logger.debug("stdout flush failed — parent may lose liveness signal")
732
+ time.sleep(retry_sleep)
733
+ continue
734
+ # Non-409 error, or exhausted attempts: stop retrying.
735
+ break
736
+
737
+ if ccp_blocked:
738
+ final_msg = (
739
+ f"[location={name}] blocked by CCP-integrated PSTN backend "
740
+ f"cleanup — Webex backend has not yet released internal trunk "
741
+ f"references. This typically clears in 1-4 hours for dCloud/CCP "
742
+ f"orgs. Re-run `wxcli cleanup run` later (it is idempotent) to "
743
+ f"retry. No action you can take to speed this up."
744
+ )
745
+ click.echo(final_msg, nl=True)
746
+ try:
747
+ click.get_text_stream("stdout").flush()
748
+ except Exception:
749
+ logger.debug("stdout flush failed — parent may lose liveness signal")
750
+ return DeleteResult(
751
+ resource_type="Locations",
752
+ resource_id=location_id,
753
+ resource_name=name,
754
+ success=False,
755
+ error=final_msg,
756
+ ccp_blocked=True,
757
+ )
758
+
759
+ blockers = _location_blockers(api, location_id, org_id)
760
+ blocker = _extract_blocker(last_error)
761
+ if blockers:
762
+ final_msg = (
763
+ f"delete failed after {max_attempts} attempt(s); "
764
+ f"visible blockers: {', '.join(blockers)}; {last_error}"
765
+ )
766
+ elif blocker:
767
+ final_msg = (
768
+ f"delete failed after {max_attempts} attempt(s); "
769
+ f"blocked by {blocker}: {last_error}"
770
+ )
771
+ else:
772
+ final_msg = (
773
+ f"delete failed after {max_attempts} attempt(s): {last_error}"
774
+ )
775
+ click.echo(f"[location={name}] {final_msg}", nl=True)
776
+ try:
777
+ click.get_text_stream("stdout").flush()
778
+ except Exception:
779
+ logger.debug("stdout flush failed — parent may lose liveness signal")
780
+ return DeleteResult(
781
+ resource_type="Locations",
782
+ resource_id=location_id,
783
+ resource_name=name,
784
+ success=False,
785
+ error=final_msg,
786
+ )
787
+
788
+
789
+ # ---------------------------------------------------------------------------
790
+ # Number removal
791
+ # ---------------------------------------------------------------------------
792
+
793
+ def list_location_numbers(
794
+ api,
795
+ location_id: str,
796
+ org_id: str | None,
797
+ ) -> list[dict]:
798
+ """List phone numbers assigned to a location."""
799
+ url = f"{BASE}/telephony/config/numbers"
800
+ params = {"locationId": location_id}
801
+ if org_id:
802
+ params["orgId"] = org_id
803
+ try:
804
+ result = api.session.rest_get(url, params=params)
805
+ if isinstance(result, dict):
806
+ return result.get("phoneNumbers", [])
807
+ return result if isinstance(result, list) else []
808
+ except WebexError as e:
809
+ logger.warning("Failed to list numbers for location %s: %s", location_id, e)
810
+ return []
811
+
812
+
813
+ def delete_location_numbers(
814
+ api,
815
+ location_id: str,
816
+ phone_numbers: list[str],
817
+ org_id: str | None,
818
+ ) -> list[DeleteResult]:
819
+ """Delete phone numbers from a location in batches of 5 (API limit).
820
+
821
+ If 3 consecutive batch deletes fail at the same location (Issue #14),
822
+ stop iterating for that location and surface a warning. Any remaining
823
+ batches are marked as failures with a short "aborted" marker so the
824
+ final summary reflects what wasn't attempted.
825
+ """
826
+ results: list[DeleteResult] = []
827
+ url = f"{BASE}/telephony/config/locations/{location_id}/numbers"
828
+ params = {}
829
+ if org_id:
830
+ params["orgId"] = org_id
831
+
832
+ CONSECUTIVE_FAILURE_LIMIT = 3
833
+ consecutive_failures = 0
834
+ aborted_at: int | None = None
835
+
836
+ # API allows max 5 numbers per request
837
+ total = len(phone_numbers)
838
+ for i in range(0, total, 5):
839
+ batch = phone_numbers[i:i + 5]
840
+ try:
841
+ api.session.rest_delete(url, params=params, json={"phoneNumbers": batch})
842
+ consecutive_failures = 0
843
+ for num in batch:
844
+ results.append(DeleteResult(
845
+ resource_type="Numbers",
846
+ resource_id=num,
847
+ resource_name=num,
848
+ success=True,
849
+ ))
850
+ except WebexError as e:
851
+ err_str = str(e)
852
+ # CCP-integrated PSTN: number lifecycle is managed via the PSTN
853
+ # portal, not the API. Log and skip — don't treat as a failure.
854
+ if _is_ccp_integrated_error(err_str):
855
+ consecutive_failures = 0
856
+ for num in batch:
857
+ click.echo(
858
+ f"[number={num}] skipped — CCP-integrated, "
859
+ f"managed via PSTN portal",
860
+ nl=True,
861
+ )
862
+ try:
863
+ click.get_text_stream("stdout").flush()
864
+ except Exception:
865
+ logger.debug("stdout flush failed — parent may lose liveness signal")
866
+ results.append(DeleteResult(
867
+ resource_type="Numbers",
868
+ resource_id=num,
869
+ resource_name=num,
870
+ success=True,
871
+ error=None,
872
+ ccp_blocked=True,
873
+ ))
874
+ continue
875
+ consecutive_failures += 1
876
+ for num in batch:
877
+ results.append(DeleteResult(
878
+ resource_type="Numbers",
879
+ resource_id=num,
880
+ resource_name=num,
881
+ success=False,
882
+ error=err_str,
883
+ ))
884
+ if consecutive_failures >= CONSECUTIVE_FAILURE_LIMIT:
885
+ aborted_at = i + len(batch)
886
+ click.secho(
887
+ f"⚠️ Location {location_id[:12]}...: "
888
+ f"{CONSECUTIVE_FAILURE_LIMIT} consecutive number-delete "
889
+ f"batch failures — aborting number cleanup for this "
890
+ f"location",
891
+ fg="yellow",
892
+ err=False,
893
+ )
894
+ break
895
+
896
+ if aborted_at is not None:
897
+ for num in phone_numbers[aborted_at:]:
898
+ results.append(DeleteResult(
899
+ resource_type="Numbers",
900
+ resource_id=num,
901
+ resource_name=num,
902
+ success=False,
903
+ error="aborted: 3 consecutive batch failures at this location",
904
+ ))
905
+
906
+ return results
907
+
908
+
909
+ def build_number_inventory(
910
+ api,
911
+ location_ids: list[str],
912
+ org_id: str | None,
913
+ skipped_ext_only: list[str] | None = None,
914
+ ) -> dict[str, list[str]]:
915
+ """Build a map of location_id -> [phone_numbers] for cleanup.
916
+
917
+ Returns only locations that have numbers to remove.
918
+
919
+ If ``skipped_ext_only`` is provided, each extension-only record encountered
920
+ is appended to it as a "loc_id: owner" entry so the caller can list the
921
+ skips in the dry-run/summary output (Issue #1).
922
+ """
923
+ inventory: dict[str, list[str]] = {}
924
+ for loc_id in location_ids:
925
+ numbers = list_location_numbers(api, loc_id, org_id)
926
+ phone_nums = []
927
+ ext_only = 0
928
+ for n in numbers:
929
+ # Extension-only records have no phoneNumber field — they are
930
+ # extension owners (person/workspace/virtual line), not PSTN
931
+ # number resources, and get released when the owner is deleted.
932
+ if not n.get("phoneNumber"):
933
+ ext_only += 1
934
+ if skipped_ext_only is not None:
935
+ owner = n.get("owner") or {}
936
+ # Best-effort human label: displayName → firstName/lastName
937
+ # → owner id → extension.
938
+ label = (
939
+ owner.get("displayName")
940
+ or " ".join(
941
+ part for part in (
942
+ owner.get("firstName"), owner.get("lastName"),
943
+ ) if part
944
+ ).strip()
945
+ or owner.get("id")
946
+ or n.get("extension")
947
+ or "(unknown)"
948
+ )
949
+ skipped_ext_only.append(f"{loc_id[:12]}...: {label}")
950
+ continue
951
+ if n.get("mainNumber") is True:
952
+ continue
953
+ phone_nums.append(n["phoneNumber"])
954
+ if ext_only:
955
+ logger.info(
956
+ "location %s: skipped %d extension-only record(s) "
957
+ "(extension owners, not phone numbers)",
958
+ loc_id, ext_only,
959
+ )
960
+ if phone_nums:
961
+ inventory[loc_id] = phone_nums
962
+ return inventory
963
+
964
+
965
+ # ---------------------------------------------------------------------------
966
+ # Formatters
967
+ # ---------------------------------------------------------------------------
968
+
969
+ def format_dry_run(inventory: dict[str, list[dict]]) -> str:
970
+ """Format the inventory as a dry-run report."""
971
+ lines = ["", "=== DRY RUN — Resources that would be deleted ===", ""]
972
+ total = 0
973
+ for layer in DELETION_LAYERS:
974
+ for key in layer:
975
+ if key not in inventory:
976
+ continue
977
+ items = inventory[key]
978
+ rt = RESOURCE_TYPES[key]
979
+ lines.append(f" {rt.name:<25s} {len(items):>4d}")
980
+ for item in items[:5]:
981
+ name = item.get(rt.name_field, item.get("displayName", "unnamed"))
982
+ rid = item.get(rt.id_field, "?")
983
+ lines.append(f" - {name} ({rid[:12]}...)")
984
+ if len(items) > 5:
985
+ lines.append(f" ... and {len(items) - 5} more")
986
+ total += len(items)
987
+
988
+ lines.append("")
989
+ lines.append(f" {'TOTAL':<25s} {total:>4d}")
990
+ lines.append("")
991
+ return "\n".join(lines)
992
+
993
+
994
+ # ---------------------------------------------------------------------------
995
+ # CLI Command
996
+ # ---------------------------------------------------------------------------
997
+
998
+ def _resolve_location_ids(
999
+ api, scope: str | None, org_id: str | None,
1000
+ unresolved: list[str] | None = None,
1001
+ ) -> list[str] | None:
1002
+ """Resolve --scope names/IDs to location IDs. Returns None for all.
1003
+
1004
+ If ``unresolved`` is provided, any scope tokens that don't match a
1005
+ location name or ID are appended to it (Issue #2). The caller is
1006
+ responsible for surfacing them in the output before the deletion
1007
+ summary so the operator has a chance to Ctrl+C.
1008
+ """
1009
+ if not scope:
1010
+ return None
1011
+
1012
+ # Fetch all locations
1013
+ params = {}
1014
+ if org_id:
1015
+ params["orgId"] = org_id
1016
+ all_locs = list(api.session.follow_pagination(
1017
+ url=f"{BASE}/locations", params=params, item_key="items",
1018
+ ))
1019
+
1020
+ # Build name->id and id->id maps
1021
+ name_map = {loc["name"]: loc["id"] for loc in all_locs}
1022
+ id_set = {loc["id"] for loc in all_locs}
1023
+
1024
+ requested = [s.strip() for s in scope.split(",")]
1025
+ resolved = []
1026
+ for r in requested:
1027
+ if r in id_set:
1028
+ resolved.append(r)
1029
+ elif r in name_map:
1030
+ resolved.append(name_map[r])
1031
+ else:
1032
+ if unresolved is not None:
1033
+ unresolved.append(r)
1034
+
1035
+ if not resolved:
1036
+ console.print("[red]No valid locations found for the given scope.[/red]")
1037
+ if unresolved:
1038
+ console.print(
1039
+ f"[yellow]Unresolved names: {', '.join(unresolved)}[/yellow]"
1040
+ )
1041
+ raise typer.Exit(1)
1042
+
1043
+ return resolved
1044
+
1045
+
1046
+ @app.command("run")
1047
+ def cleanup_run(
1048
+ scope: str = typer.Option(
1049
+ None, "--scope",
1050
+ help="Comma-separated location names or IDs to limit cleanup scope.",
1051
+ ),
1052
+ all_resources: bool = typer.Option(
1053
+ False, "--all",
1054
+ help="Clean up the entire org (required if --scope not given).",
1055
+ ),
1056
+ include_users: bool = typer.Option(
1057
+ False, "--include-users",
1058
+ help="Also delete users (destructive — disabled by default).",
1059
+ ),
1060
+ include_locations: bool = typer.Option(
1061
+ False, "--include-locations",
1062
+ help="Also delete locations (destructive — disabled by default).",
1063
+ ),
1064
+ exclude_user_domains: str = typer.Option(
1065
+ None, "--exclude-user-domains",
1066
+ help="Comma-separated email domains to exclude from user deletion (e.g. 'wbx.ai,corp.com').",
1067
+ ),
1068
+ dry_run: bool = typer.Option(
1069
+ False, "--dry-run",
1070
+ help="Show what would be deleted without actually deleting.",
1071
+ ),
1072
+ max_concurrent: int = typer.Option(
1073
+ 5, "--max-concurrent",
1074
+ help="Max parallel deletions per layer.",
1075
+ ),
1076
+ force: bool = typer.Option(
1077
+ False, "--force",
1078
+ help="Skip confirmation prompt.",
1079
+ ),
1080
+ debug: bool = typer.Option(False, "--debug"),
1081
+ ):
1082
+ """Delete Webex Calling resources in dependency-safe order.
1083
+
1084
+ Examples:
1085
+ wxcli cleanup run --scope "HQ,Branch" --dry-run
1086
+ wxcli cleanup run --scope "HQ" --include-users --force
1087
+ wxcli cleanup run --all --include-locations --force
1088
+ wxcli cleanup run --all --include-users --exclude-user-domains "wbx.ai,corp.com"
1089
+ """
1090
+ if not scope and not all_resources:
1091
+ console.print(
1092
+ "[red]Must specify --scope or --all.[/red]\n"
1093
+ "Use --dry-run to preview what would be deleted."
1094
+ )
1095
+ raise typer.Exit(1)
1096
+
1097
+ api = get_api(debug=debug)
1098
+ org_id = get_org_id()
1099
+
1100
+ # Resolve location scope
1101
+ console.print("[bold]Resolving scope...[/bold]")
1102
+ unresolved_scope: list[str] = []
1103
+ location_ids = _resolve_location_ids(
1104
+ api, scope, org_id, unresolved=unresolved_scope,
1105
+ )
1106
+
1107
+ # Issue #2: surface unresolved --scope tokens BEFORE inventory/deletion
1108
+ # so the operator has a chance to Ctrl+C rather than finding them buried
1109
+ # in scrollback after cleanup runs.
1110
+ if unresolved_scope:
1111
+ click.secho(
1112
+ f"⚠️ Unresolved location names (will be ignored): "
1113
+ f"{', '.join(unresolved_scope)}",
1114
+ fg="yellow",
1115
+ err=False,
1116
+ )
1117
+
1118
+ if location_ids:
1119
+ console.print(f" Scoped to {len(location_ids)} location(s)")
1120
+ else:
1121
+ console.print(" Org-wide cleanup")
1122
+
1123
+ # For location-scoped listing, we always need location IDs
1124
+ # If --all and no scope, fetch all locations for per-location listing
1125
+ if location_ids is None:
1126
+ params = {}
1127
+ if org_id:
1128
+ params["orgId"] = org_id
1129
+ all_locs = list(api.session.follow_pagination(
1130
+ url=f"{BASE}/locations", params=params, item_key="items",
1131
+ ))
1132
+ all_location_ids = [loc["id"] for loc in all_locs]
1133
+ else:
1134
+ all_location_ids = location_ids
1135
+
1136
+ # Build inventory — always pass all_location_ids so location-scoped types get enumerated
1137
+ # scope_filter=False for --all mode: don't exclude resources missing locationId
1138
+ is_scoped = scope is not None
1139
+ console.print("[bold]Building inventory...[/bold]")
1140
+ inventory = build_inventory(
1141
+ api, org_id, location_ids=all_location_ids,
1142
+ include_users=include_users, include_locations=include_locations,
1143
+ scope_filter=is_scoped,
1144
+ )
1145
+
1146
+ # Filter users by excluded domains
1147
+ if exclude_user_domains and "users" in inventory:
1148
+ excluded = {d.strip().lower() for d in exclude_user_domains.split(",")}
1149
+ before = len(inventory["users"])
1150
+
1151
+ def _user_matches_excluded(user: dict) -> bool:
1152
+ # People API returns emails as a list
1153
+ emails = user.get("emails", [])
1154
+ if not emails:
1155
+ return False
1156
+ primary = emails[0].lower() if isinstance(emails, list) else str(emails).lower()
1157
+ domain = primary.split("@")[-1]
1158
+ return any(domain == d or domain.endswith(f".{d}") for d in excluded)
1159
+
1160
+ inventory["users"] = [u for u in inventory["users"] if not _user_matches_excluded(u)]
1161
+ filtered = before - len(inventory["users"])
1162
+ # Issue #3: always print the exclusion notice when the flag is set so
1163
+ # the operator sees which domains are being kept and how many users
1164
+ # that covers — even when zero matched (e.g. a typo in the domain list).
1165
+ click.echo(
1166
+ f"Excluding users from domains: {', '.join(sorted(excluded))} "
1167
+ f"({filtered} user{'s' if filtered != 1 else ''} excluded)"
1168
+ )
1169
+ if not inventory["users"]:
1170
+ del inventory["users"]
1171
+ elif exclude_user_domains:
1172
+ # Flag set but no users to filter (e.g. --include-users not passed,
1173
+ # or inventory produced zero users). Still surface it so the operator
1174
+ # doesn't wonder whether the flag was honored.
1175
+ excluded = {d.strip().lower() for d in exclude_user_domains.split(",")}
1176
+ click.echo(
1177
+ f"Excluding users from domains: {', '.join(sorted(excluded))} "
1178
+ f"(0 users excluded)"
1179
+ )
1180
+
1181
+ # Build number inventory for locations (needed for Layer 12)
1182
+ number_inventory: dict[str, list[str]] = {}
1183
+ skipped_ext_only: list[str] = []
1184
+ if include_locations:
1185
+ console.print("[bold]Checking for phone numbers on locations...[/bold]")
1186
+ number_inventory = build_number_inventory(
1187
+ api, all_location_ids, org_id,
1188
+ skipped_ext_only=skipped_ext_only,
1189
+ )
1190
+ total_nums = sum(len(v) for v in number_inventory.values())
1191
+ if total_nums:
1192
+ console.print(f" Found {total_nums} number(s) across {len(number_inventory)} location(s)")
1193
+ # Add to inventory for dry-run display
1194
+ inventory["numbers"] = [
1195
+ {"id": num, "name": num, "locationId": loc_id}
1196
+ for loc_id, nums in number_inventory.items()
1197
+ for num in nums
1198
+ ]
1199
+
1200
+ # Issue #1: surface extension-only records that were intentionally skipped
1201
+ # (extension owners, not PSTN numbers — they get released when the owner
1202
+ # is deleted). Print BEFORE the deletion summary/dry-run so the operator
1203
+ # can see them in the same pane where the totals live.
1204
+ if skipped_ext_only:
1205
+ click.echo(
1206
+ f"\nSkipped {len(skipped_ext_only)} extension-only record(s):"
1207
+ )
1208
+ for entry in skipped_ext_only:
1209
+ click.echo(f" - {entry}")
1210
+
1211
+ if not inventory:
1212
+ console.print("[green]Nothing to delete — org is clean.[/green]")
1213
+ return
1214
+
1215
+ # Dry run
1216
+ console.print(format_dry_run(inventory))
1217
+
1218
+ if dry_run:
1219
+ console.print("[yellow]Dry run complete. No resources were deleted.[/yellow]")
1220
+ return
1221
+
1222
+ # Confirmation
1223
+ total = sum(len(v) for v in inventory.values())
1224
+ if not force:
1225
+ typer.confirm(
1226
+ f"\nDelete {total} resources? This cannot be undone",
1227
+ abort=True,
1228
+ )
1229
+
1230
+ # Execute layer by layer
1231
+ all_results: list[DeleteResult] = []
1232
+ for i, layer_keys in enumerate(DELETION_LAYERS):
1233
+ # Check if this layer has any work
1234
+ has_work = any(k in inventory for k in layer_keys)
1235
+ if not has_work:
1236
+ continue
1237
+
1238
+ # Skip users/locations/numbers layers if not included
1239
+ if "users" in layer_keys and not include_users:
1240
+ continue
1241
+ if "locations" in layer_keys and not include_locations:
1242
+ continue
1243
+ if "numbers" in layer_keys and not include_locations:
1244
+ continue
1245
+
1246
+ layer_names = [RESOURCE_TYPES[k].name for k in layer_keys if k in inventory]
1247
+ console.print(f"\n[bold]Layer {i + 1}:[/bold] {', '.join(layer_names)}")
1248
+
1249
+ # Special handling for numbers: delete per-location with batched body
1250
+ if "numbers" in layer_keys and number_inventory:
1251
+ for loc_id, phone_nums in number_inventory.items():
1252
+ console.print(f" Removing {len(phone_nums)} number(s) from location {loc_id[:12]}...")
1253
+ num_results = delete_location_numbers(api, loc_id, phone_nums, org_id)
1254
+ all_results.extend(num_results)
1255
+ ok = sum(1 for r in num_results if r.success)
1256
+ fail = sum(1 for r in num_results if not r.success)
1257
+ console.print(f" {ok} removed, {fail} failed")
1258
+ continue
1259
+
1260
+ # Special handling for locations: best-effort telephony detach first,
1261
+ # then wait, then delete.
1262
+ if "locations" in layer_keys and "locations" in inventory:
1263
+ loc_items = inventory["locations"]
1264
+ # Map location_id -> name for failure messages (Issue #11).
1265
+ loc_names = {item["id"]: item.get("name", item["id"]) for item in loc_items}
1266
+ # Phase 1: best-effort telephony detach. This endpoint is not a
1267
+ # reliable public disable-calling contract, so avoid overstating
1268
+ # what success means in operator-facing output.
1269
+ console.print(" Attempting telephony detach on locations...")
1270
+ disable_results = []
1271
+ with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
1272
+ futures = {
1273
+ executor.submit(
1274
+ disable_location_calling, api, item["id"], org_id,
1275
+ ): item
1276
+ for item in loc_items
1277
+ }
1278
+ for future in as_completed(futures):
1279
+ disable_results.append(future.result())
1280
+
1281
+ disabled_ok = [r for r in disable_results if r.success]
1282
+ disabled_failed = [r for r in disable_results if not r.success]
1283
+ console.print(
1284
+ f" Telephony detach request accepted for "
1285
+ f"{len(disabled_ok)}/{len(loc_items)} locations"
1286
+ )
1287
+
1288
+ # Partition the inventory into two delete cohorts by disable outcome
1289
+ # (Issue #11 / Finding #6). Locations where the detach request
1290
+ # failed do NOT
1291
+ # pay the 90-second propagation wait — they proceed to delete
1292
+ # immediately. Locations where it succeeded wait 90s (the calling
1293
+ # subsystem propagation window) before the delete phase.
1294
+ succeeded_ids = {r.resource_id for r in disabled_ok}
1295
+ items_disable_ok = [it for it in loc_items if it["id"] in succeeded_ids]
1296
+ items_disable_failed = [
1297
+ it for it in loc_items if it["id"] not in succeeded_ids
1298
+ ]
1299
+
1300
+ # Issue #11: surface each detach-request failure explicitly. Delete will
1301
+ # still be attempted (likely 409), but the existing
1302
+ # delete_location error-handling catches that and the caller
1303
+ # can re-run cleanup idempotently.
1304
+ for r in disabled_failed:
1305
+ name = loc_names.get(r.resource_id, r.resource_id)
1306
+ click.secho(
1307
+ f"⚠️ Location {name}: telephony detach request failed — "
1308
+ f"skipping 90s wait; will attempt delete anyway",
1309
+ fg="yellow",
1310
+ err=False,
1311
+ )
1312
+
1313
+ total_locs = len(loc_items)
1314
+ layer_num = i + 1
1315
+ console.print(
1316
+ f" Deleting locations (up to "
1317
+ f"{LOCATION_DELETE_MAX_ATTEMPTS} attempts × "
1318
+ f"{LOCATION_DELETE_RETRY_SLEEP}s per location)..."
1319
+ )
1320
+ console.print(
1321
+ f" Layer {layer_num} (location): 0/{total_locs} deleted, "
1322
+ f"0 retrying, {total_locs} waiting"
1323
+ )
1324
+
1325
+ deleted = 0
1326
+ failed = 0
1327
+ completed = 0
1328
+ loc_results: list[DeleteResult] = []
1329
+
1330
+ def _delete_cohort(cohort_items: list[dict]) -> None:
1331
+ nonlocal deleted, failed, completed
1332
+ if not cohort_items:
1333
+ return
1334
+ with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
1335
+ futures = {
1336
+ executor.submit(delete_location, api, item, org_id): item
1337
+ for item in cohort_items
1338
+ }
1339
+ for future in as_completed(futures):
1340
+ res = future.result()
1341
+ loc_results.append(res)
1342
+ all_results.append(res)
1343
+ completed += 1
1344
+ if res.success:
1345
+ deleted += 1
1346
+ else:
1347
+ failed += 1
1348
+ # The remaining in-flight futures are the ones still
1349
+ # looping through retry attempts; the rest are
1350
+ # "waiting" in the executor queue (only meaningful
1351
+ # when max_concurrent < total_locs).
1352
+ in_flight = min(
1353
+ max_concurrent, total_locs - completed,
1354
+ )
1355
+ waiting = max(0, total_locs - completed - in_flight)
1356
+ console.print(
1357
+ f" Layer {layer_num} (location): "
1358
+ f"{deleted}/{total_locs} deleted, "
1359
+ f"{in_flight} retrying, {waiting} waiting"
1360
+ )
1361
+
1362
+ # Phase 3a: delete locations where disable FAILED — no 90s wait.
1363
+ # These are expected to 409 on delete (calling still attached),
1364
+ # but we try anyway so a re-run after the operator fixes the
1365
+ # root cause can skip straight to success.
1366
+ _delete_cohort(items_disable_failed)
1367
+
1368
+ # Phase 2 (deferred): wait 90s only if any disables actually
1369
+ # succeeded. When every disable failed, the 90s wait buys nothing.
1370
+ # The wait happens BETWEEN cohorts so the failed-disable locations
1371
+ # don't pay the penalty.
1372
+ if items_disable_ok:
1373
+ console.print(" Waiting 90s for backend propagation...")
1374
+ time.sleep(90)
1375
+
1376
+ # Phase 3b: delete locations where disable SUCCEEDED — after wait.
1377
+ _delete_cohort(items_disable_ok)
1378
+
1379
+ console.print(f" Done: {deleted} deleted, {failed} failed")
1380
+ continue
1381
+
1382
+ results = execute_layer(api, layer_keys, inventory, org_id, max_concurrent)
1383
+ all_results.extend(results)
1384
+
1385
+ successes = sum(1 for r in results if r.success)
1386
+ failures = sum(1 for r in results if not r.success)
1387
+ console.print(f" Done: {successes} deleted, {failures} failed")
1388
+
1389
+ # Final summary
1390
+ console.print(format_results_summary(all_results))
1391
+
1392
+
1393
+ def format_results_summary(results: list[DeleteResult]) -> str:
1394
+ """Format a summary of deletion results."""
1395
+ successes = [r for r in results if r.success]
1396
+ real_failures = [r for r in results if not r.success and not r.ccp_blocked]
1397
+ ccp_blocked = [r for r in results if r.ccp_blocked]
1398
+ ccp_blocked_locations = [
1399
+ r for r in ccp_blocked if r.resource_type == "Locations"
1400
+ ]
1401
+
1402
+ lines = ["", "=== Cleanup Results ===", ""]
1403
+ lines.append(f" Deleted: {len(successes)}")
1404
+ lines.append(f" Failed: {len(real_failures)}")
1405
+ if ccp_blocked:
1406
+ lines.append(f" CCP-blocked: {len(ccp_blocked)}")
1407
+
1408
+ if real_failures:
1409
+ lines.append("")
1410
+ lines.append(" Failures:")
1411
+ for f in real_failures:
1412
+ lines.append(f" - {f.resource_type} {f.resource_name} ({f.resource_id}): {f.error}")
1413
+
1414
+ if ccp_blocked_locations:
1415
+ lines.append("")
1416
+ lines.append(
1417
+ f" NOTE: {len(ccp_blocked_locations)} location(s) blocked by "
1418
+ f"CCP backend — retry in a few hours:"
1419
+ )
1420
+ for r in ccp_blocked_locations:
1421
+ lines.append(f" - {r.resource_name} ({r.resource_id})")
1422
+ lines.append(
1423
+ " Re-run `wxcli cleanup run` later; the command is idempotent."
1424
+ )
1425
+
1426
+ lines.append("")
1427
+ return "\n".join(lines)