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,246 @@
1
+ """CUCM digit pattern compiler and overlap detection.
2
+
3
+ Compiles CUCM route pattern strings into Python regexes and detects
4
+ whether two patterns can match the same digit string. Used by css_mapper
5
+ for ordering conflict detection.
6
+
7
+ CUCM pattern symbols (from 04-css-decomposition.md):
8
+ X — single digit 0-9
9
+ ! — one or more digits (greedy wildcard)
10
+ [n-m] — digit range
11
+ [^n] — negated digit
12
+ @ — national numbering plan macro (country-specific)
13
+ . — separator (between access code and pattern)
14
+ + — E.164 prefix (literal)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ import re
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # @ macro expansion — intentionally broad for overlap detection safety.
25
+ # Real CUCM '@' expands to the loaded National Numbering Plan file, which
26
+ # varies by cluster/country. This catch-all never produces false negatives
27
+ # in overlap detection (it over-matches, so overlapping patterns are always
28
+ # detected). generate_representative_strings compensates by producing
29
+ # US-format 10-digit numbers for '@' patterns.
30
+ # CUCM @ pattern matches "any dialed number" — exact expansion varies by
31
+ # cluster/country dial plan. [0-9]{1,15} is a conservative over-match that
32
+ # never produces false negatives in overlap detection. Safe approximation.
33
+ # ---------------------------------------------------------------------------
34
+ _AT_MACRO_REGEX = "[0-9]{1,15}"
35
+
36
+
37
+ def cucm_pattern_to_regex(pattern: str) -> str:
38
+ """Convert a CUCM digit pattern to a Python regex string.
39
+
40
+ X → [0-9], ! → [0-9]+, [1-4] → [1-4], [^5] → [^5]
41
+ . → '' (stripped — separator only), + → \\+, @ → expanded
42
+
43
+ (from 03b-transform-mappers.md, cucm_pattern.py interface)
44
+ """
45
+ if not pattern or pattern.strip() == "" or pattern == ".":
46
+ raise ValueError(f"Empty or invalid CUCM pattern: {pattern!r}")
47
+
48
+ # Split on first '.' only — it's the access code separator
49
+ # (from 04-css-decomposition.md: '.' separates access code from pattern)
50
+ parts = pattern.split(".", 1)
51
+ if len(parts) == 2:
52
+ access_code, digit_pattern = parts[0], parts[1]
53
+ else:
54
+ access_code, digit_pattern = "", parts[0]
55
+
56
+ # Convert the access code (literal digits, + prefix)
57
+ regex = _convert_segment(access_code)
58
+ # Convert the digit pattern
59
+ regex += _convert_segment(digit_pattern)
60
+
61
+ return regex
62
+
63
+
64
+ def _convert_segment(segment: str) -> str:
65
+ """Convert a CUCM pattern segment to regex."""
66
+ result = []
67
+ i = 0
68
+ while i < len(segment):
69
+ ch = segment[i]
70
+ if ch == "X":
71
+ result.append("[0-9]")
72
+ elif ch == "!":
73
+ result.append("[0-9]+")
74
+ elif ch == "@":
75
+ result.append(_AT_MACRO_REGEX)
76
+ elif ch == "+":
77
+ result.append("\\+")
78
+ elif ch == "[":
79
+ # Pass through bracket expressions (ranges and negation)
80
+ try:
81
+ end = segment.index("]", i)
82
+ except ValueError:
83
+ raise ValueError(f"Unclosed bracket in CUCM pattern segment: {segment!r}")
84
+ result.append(segment[i : end + 1])
85
+ i = end
86
+ elif ch.isdigit():
87
+ result.append(ch)
88
+ # Skip any other character (shouldn't normally appear)
89
+ i += 1
90
+ return "".join(result)
91
+
92
+
93
+ def compile_cucm_pattern(pattern: str) -> re.Pattern:
94
+ """Compile a CUCM digit pattern into a Python regex Pattern object.
95
+
96
+ (from phase-02-risk-spikes.md: compile_cucm_pattern(pattern: str) -> re.Pattern)
97
+ """
98
+ regex_str = cucm_pattern_to_regex(pattern)
99
+ return re.compile(f"^{regex_str}$")
100
+
101
+
102
+ def generate_representative_strings(pattern: str, count: int = 10) -> list[str]:
103
+ """Generate digit strings that match a CUCM pattern.
104
+
105
+ Uses random generation to produce representative strings for overlap testing.
106
+ For patterns with '!' (unbounded length), generates strings at representative
107
+ lengths (5, 7, 10, 11, 15 digits for the '!' portion).
108
+ (from 04-css-decomposition.md: enumeration approach #2)
109
+ """
110
+ if not pattern or pattern.strip() == "" or pattern == ".":
111
+ raise ValueError(f"Empty or invalid CUCM pattern: {pattern!r}")
112
+
113
+ # Split on first '.' — access code separator
114
+ parts = pattern.split(".", 1)
115
+ if len(parts) == 2:
116
+ access_code, digit_pattern = parts[0], parts[1]
117
+ else:
118
+ access_code, digit_pattern = "", parts[0]
119
+
120
+ compiled = compile_cucm_pattern(pattern)
121
+ results = []
122
+
123
+ for _ in range(count * 5): # oversample to hit count
124
+ s = _generate_one(access_code) + _generate_one(digit_pattern)
125
+ if compiled.fullmatch(s):
126
+ results.append(s)
127
+ if len(results) >= count:
128
+ break
129
+
130
+ # Deduplicate but respect count
131
+ if len(results) < count:
132
+ # For exact patterns there's only one string
133
+ unique = list(dict.fromkeys(results))
134
+ # Pad with duplicates if needed
135
+ while len(results) < count and unique:
136
+ results.append(unique[0])
137
+
138
+ return results[:count]
139
+
140
+
141
+ def _generate_one(segment: str) -> str:
142
+ """Generate one random digit string matching a CUCM pattern segment."""
143
+ result = []
144
+ i = 0
145
+ while i < len(segment):
146
+ ch = segment[i]
147
+ if ch == "X":
148
+ result.append(str(random.randint(0, 9)))
149
+ elif ch == "!":
150
+ # Generate at representative lengths for the '!' portion
151
+ bang_len = random.choice([1, 2, 4, 7, 10])
152
+ result.append("".join(str(random.randint(0, 9)) for _ in range(bang_len)))
153
+ elif ch == "@":
154
+ # US national plan: generate 10-digit number
155
+ result.append(
156
+ str(random.randint(2, 9))
157
+ + "".join(str(random.randint(0, 9)) for _ in range(9))
158
+ )
159
+ elif ch == "+":
160
+ result.append("+")
161
+ elif ch == "[":
162
+ try:
163
+ end = segment.index("]", i)
164
+ except ValueError:
165
+ raise ValueError(f"Unclosed bracket in CUCM pattern segment: {segment!r}")
166
+ bracket_content = segment[i + 1 : end]
167
+ digit = _random_from_bracket(bracket_content)
168
+ result.append(digit)
169
+ i = end
170
+ elif ch.isdigit():
171
+ result.append(ch)
172
+ i += 1
173
+ return "".join(result)
174
+
175
+
176
+ def _random_from_bracket(content: str) -> str:
177
+ """Pick a random digit matching a bracket expression like '2-9' or '^0'."""
178
+ negated = content.startswith("^")
179
+ if negated:
180
+ content = content[1:]
181
+
182
+ # Build set of allowed digits
183
+ allowed = set()
184
+ i = 0
185
+ while i < len(content):
186
+ if i + 2 < len(content) and content[i + 1] == "-":
187
+ start, end = int(content[i]), int(content[i + 2])
188
+ allowed.update(range(start, end + 1))
189
+ i += 3
190
+ else:
191
+ allowed.add(int(content[i]))
192
+ i += 1
193
+
194
+ if negated:
195
+ allowed = set(range(10)) - allowed
196
+
197
+ if not allowed:
198
+ return "0"
199
+ return str(random.choice(sorted(allowed)))
200
+
201
+
202
+ def cucm_patterns_overlap(pattern_a: str, pattern_b: str) -> bool:
203
+ """Return True if two CUCM patterns can match any common digit string.
204
+
205
+ Uses enumeration approach: generate representative digit strings from each
206
+ pattern's match space and test against the other. Tests at representative
207
+ lengths (4, 7, 10, 11, 15 digits) for patterns involving !.
208
+ (from 04-css-decomposition.md, overlap detection via enumeration)
209
+ (from 03b-transform-mappers.md, cucm_pattern.py interface)
210
+ """
211
+ compiled_a = compile_cucm_pattern(pattern_a)
212
+ compiled_b = compile_cucm_pattern(pattern_b)
213
+
214
+ # Generate samples from A, test against B
215
+ samples_a = generate_representative_strings(pattern_a, count=50)
216
+ for s in samples_a:
217
+ if compiled_b.fullmatch(s):
218
+ return True
219
+
220
+ # Generate samples from B, test against A
221
+ samples_b = generate_representative_strings(pattern_b, count=50)
222
+ for s in samples_b:
223
+ if compiled_a.fullmatch(s):
224
+ return True
225
+
226
+ return False
227
+
228
+
229
+ def classify_block_pattern(
230
+ pattern: str,
231
+ category_rules: list[dict],
232
+ ) -> str | None:
233
+ """Classify a CUCM blocking pattern into a Webex permission category.
234
+
235
+ Returns category string (e.g., 'international', 'premium') or None if
236
+ unclassifiable. Uses configurable rules from migration config.
237
+ (from 03b-transform-mappers.md, cucm_pattern.py interface)
238
+
239
+ A rule matches if the blocking pattern is identical to the rule's pattern
240
+ (exact string match). For more sophisticated matching (e.g., pattern
241
+ subsumption), extend this function.
242
+ """
243
+ for rule in category_rules:
244
+ if rule["cucm_pattern"] == pattern:
245
+ return rule["webex_category"]
246
+ return None
@@ -0,0 +1,301 @@
1
+ """Decision model helpers for querying, summarizing, and formatting decisions.
2
+
3
+ Complements the Mapper._fingerprint() method with higher-level helpers
4
+ for CLI reporting and programmatic queries.
5
+
6
+ (from 03b-transform-mappers.md, shared patterns)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from typing import Any
13
+
14
+ from wxcli.migration.store import MigrationStore
15
+
16
+
17
+ def summarize_decisions(decisions: list[dict[str, Any]]) -> dict[str, dict[str, int]]:
18
+ """Group decisions by type and severity.
19
+
20
+ Returns a nested dict: {type: {severity: count}}.
21
+
22
+ Args:
23
+ decisions: list of decision dicts (from store.get_all_decisions() or similar).
24
+
25
+ Example return::
26
+
27
+ {
28
+ "DEVICE_INCOMPATIBLE": {"HIGH": 3, "MEDIUM": 1},
29
+ "MISSING_DATA": {"MEDIUM": 5, "LOW": 2},
30
+ }
31
+ """
32
+ summary: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
33
+ for dec in decisions:
34
+ dec_type = dec.get("type", "UNKNOWN")
35
+ severity = dec.get("severity", "UNKNOWN")
36
+ summary[dec_type][severity] += 1
37
+ # Convert defaultdicts to regular dicts for clean serialization
38
+ return {k: dict(v) for k, v in summary.items()}
39
+
40
+
41
+ def format_decision_report(decisions: list[dict[str, Any]]) -> str:
42
+ """Human-readable decision summary for CLI output.
43
+
44
+ Produces a compact multi-line report grouped by type with counts
45
+ and resolution status.
46
+
47
+ Args:
48
+ decisions: list of decision dicts.
49
+
50
+ Returns:
51
+ Multi-line string suitable for console display.
52
+ """
53
+ if not decisions:
54
+ return "No decisions to report."
55
+
56
+ summary = summarize_decisions(decisions)
57
+
58
+ # Count resolved vs pending
59
+ total = len(decisions)
60
+ resolved = sum(1 for d in decisions if d.get("chosen_option") is not None)
61
+ pending = total - resolved
62
+
63
+ lines: list[str] = [
64
+ f"Decision Report: {total} total ({resolved} resolved, {pending} pending)",
65
+ "-" * 60,
66
+ ]
67
+
68
+ # Sort types by total count descending
69
+ type_totals = {
70
+ dec_type: sum(sev_counts.values())
71
+ for dec_type, sev_counts in summary.items()
72
+ }
73
+ for dec_type in sorted(type_totals, key=type_totals.get, reverse=True): # type: ignore[arg-type]
74
+ sev_counts = summary[dec_type]
75
+ sev_parts = ", ".join(
76
+ f"{sev}: {count}" for sev, count in sorted(sev_counts.items())
77
+ )
78
+ lines.append(f" {dec_type}: {type_totals[dec_type]} ({sev_parts})")
79
+
80
+ # Pending breakdown
81
+ if pending > 0:
82
+ lines.append("")
83
+ lines.append(f"Pending decisions requiring resolution: {pending}")
84
+ pending_by_severity: dict[str, int] = defaultdict(int)
85
+ for d in decisions:
86
+ if d.get("chosen_option") is None:
87
+ pending_by_severity[d.get("severity", "UNKNOWN")] += 1
88
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
89
+ count = pending_by_severity.get(sev, 0)
90
+ if count:
91
+ lines.append(f" {sev}: {count}")
92
+
93
+ return "\n".join(lines)
94
+
95
+
96
+ def decisions_by_type(
97
+ store: MigrationStore, decision_type: str
98
+ ) -> list[dict[str, Any]]:
99
+ """Query all decisions of a given type from the store.
100
+
101
+ Args:
102
+ store: MigrationStore instance.
103
+ decision_type: DecisionType value string (e.g., "DEVICE_INCOMPATIBLE").
104
+
105
+ Returns:
106
+ List of decision dicts matching the given type.
107
+ """
108
+ all_decisions = store.get_all_decisions()
109
+ return [d for d in all_decisions if d.get("type") == decision_type]
110
+
111
+
112
+ def pending_decisions(store: MigrationStore) -> list[dict[str, Any]]:
113
+ """Query all unresolved decisions from the store.
114
+
115
+ Returns:
116
+ List of decision dicts where chosen_option is None.
117
+ """
118
+ all_decisions = store.get_all_decisions()
119
+ return [d for d in all_decisions if d.get("chosen_option") is None]
120
+
121
+
122
+ def resolved_decisions(store: MigrationStore) -> list[dict[str, Any]]:
123
+ """Query all resolved decisions from the store.
124
+
125
+ Returns:
126
+ List of decision dicts where chosen_option is not None.
127
+ """
128
+ all_decisions = store.get_all_decisions()
129
+ return [d for d in all_decisions if d.get("chosen_option") is not None]
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Auto-apply / needs-input classification
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def classify_decisions(
137
+ store: MigrationStore,
138
+ config: dict[str, Any],
139
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
140
+ """Split pending decisions into auto-apply and needs-input groups.
141
+
142
+ Auto-apply = pending decisions that the project's ``config["auto_rules"]``
143
+ would resolve. Each item is augmented with ``auto_choice`` and
144
+ ``auto_reason`` keys (from ``preview_auto_rules``).
145
+
146
+ Needs-input = pending decisions that no rule matches.
147
+
148
+ Both groups exclude already-resolved decisions and ``__stale__``
149
+ decisions.
150
+ """
151
+ from wxcli.migration.transform.rules import preview_auto_rules
152
+
153
+ auto_apply = preview_auto_rules(store, config)
154
+ auto_apply_ids = {d["decision_id"] for d in auto_apply}
155
+
156
+ all_decisions = store.get_all_decisions()
157
+ needs_input = [
158
+ d for d in all_decisions
159
+ if d.get("chosen_option") is None
160
+ and d["decision_id"] not in auto_apply_ids
161
+ ]
162
+
163
+ return auto_apply, needs_input
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Decision review export (markdown)
168
+ # ---------------------------------------------------------------------------
169
+
170
+ # Group labels for the needs-input section
171
+ _DECISION_CATEGORY: dict[str, str] = {
172
+ "LOCATION_AMBIGUOUS": "Location",
173
+ "DEVICE_FIRMWARE_CONVERTIBLE": "Device",
174
+ "DEVICE_INCOMPATIBLE": "Device",
175
+ "WORKSPACE_LICENSE_TIER": "Workspace",
176
+ "WORKSPACE_TYPE_UNCERTAIN": "Workspace",
177
+ "HOTDESK_DN_CONFLICT": "Workspace",
178
+ "EXTENSION_CONFLICT": "User",
179
+ "DN_AMBIGUOUS": "User",
180
+ "DUPLICATE_USER": "User",
181
+ "SHARED_LINE_COMPLEX": "User",
182
+ "MISSING_DATA": "Data Quality",
183
+ "CSS_ROUTING_MISMATCH": "Routing",
184
+ "CALLING_PERMISSION_MISMATCH": "Permissions",
185
+ "VOICEMAIL_INCOMPATIBLE": "Voicemail",
186
+ "FEATURE_APPROXIMATION": "Feature",
187
+ "NUMBER_CONFLICT": "Number",
188
+ }
189
+
190
+
191
+ def generate_decision_review(
192
+ store: MigrationStore,
193
+ project_id: str,
194
+ config: dict[str, Any],
195
+ ) -> str:
196
+ """Generate a markdown decision review file with auto-apply and needs-input sections."""
197
+ auto_apply, needs_input = classify_decisions(store, config)
198
+
199
+ lines: list[str] = []
200
+ lines.append(f"# Migration Decision Review — {project_id}")
201
+ lines.append(f"Generated: {_now_date()}")
202
+ lines.append("")
203
+
204
+ # --- Section 1: Auto-apply ---
205
+ lines.append(f"## Auto-Apply ({len(auto_apply)} decisions)")
206
+ lines.append("")
207
+ if auto_apply:
208
+ lines.append("These have only one sensible resolution and will be applied when you run")
209
+ lines.append("`wxcli cucm decide --apply-auto`. Review and raise any concerns before applying.")
210
+ lines.append("")
211
+ lines.append("| ID | Type | Object | Resolution | Reason |")
212
+ lines.append("|----|------|--------|------------|--------|")
213
+ for d in auto_apply:
214
+ did = d.get("decision_id", "")
215
+ dtype = d.get("type", "")
216
+ obj_name = _object_name(d)
217
+ choice = d.get("auto_choice", "")
218
+ reason = d.get("auto_reason", "")
219
+ lines.append(f"| {did} | {dtype} | {obj_name} | {choice} | {reason} |")
220
+ else:
221
+ lines.append("No auto-apply decisions.")
222
+ lines.append("")
223
+
224
+ # --- Section 2: Needs input ---
225
+ lines.append(f"## Needs Your Input ({len(needs_input)} decisions)")
226
+ lines.append("")
227
+ if needs_input:
228
+ lines.append("Each decision requires admin judgment. Options are listed — discuss with")
229
+ lines.append("your migration team if needed. Resolve via `wxcli cucm decide <ID> <choice>`")
230
+ lines.append("or tell the migration agent your choices.")
231
+ lines.append("")
232
+
233
+ # Group by category
234
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
235
+ for d in needs_input:
236
+ cat = _DECISION_CATEGORY.get(d.get("type", ""), "Other")
237
+ grouped[cat].append(d)
238
+
239
+ for category in sorted(grouped.keys()):
240
+ decs = grouped[category]
241
+ lines.append(f"### {category} Decisions ({len(decs)})")
242
+ lines.append("")
243
+ lines.append("| ID | Severity | Summary | Options |")
244
+ lines.append("|----|----------|---------|---------|")
245
+ for d in decs:
246
+ did = d.get("decision_id", "")
247
+ sev = d.get("severity", "")
248
+ summary = d.get("summary", "").replace("|", "/")
249
+ opts = _format_options_short(d.get("options", []))
250
+ lines.append(f"| {did} | {sev} | {summary} | {opts} |")
251
+
252
+ # Detail section: show context for each decision
253
+ lines.append("")
254
+ for d in decs:
255
+ did = d.get("decision_id", "")
256
+ lines.append(f"**{did}** — {d.get('summary', '')}")
257
+ lines.append("")
258
+ for i, opt in enumerate(d.get("options", [])):
259
+ letter = chr(ord("a") + i)
260
+ opt_id = opt.get("id", "") if isinstance(opt, dict) else str(opt)
261
+ label = opt.get("label", opt_id) if isinstance(opt, dict) else str(opt)
262
+ impact = opt.get("impact", "") if isinstance(opt, dict) else ""
263
+ lines.append(f" {letter}) **{label}** (`{opt_id}`) — {impact}")
264
+ lines.append("")
265
+ else:
266
+ lines.append("No decisions require input — all are auto-apply.")
267
+ lines.append("")
268
+
269
+ return "\n".join(lines)
270
+
271
+
272
+ def _object_name(decision: dict[str, Any]) -> str:
273
+ """Extract a readable object name from a decision's context or affected_objects."""
274
+ ctx = decision.get("context", {})
275
+ for key in ("device_name", "name", "canonical_id", "object_name"):
276
+ val = ctx.get(key)
277
+ if val:
278
+ return str(val)
279
+ affected = ctx.get("_affected_objects", [])
280
+ if affected:
281
+ return str(affected[0])
282
+ return "unknown"
283
+
284
+
285
+ def _format_options_short(options: list) -> str:
286
+ """Format options as a compact lettered list for table cells."""
287
+ parts = []
288
+ for i, opt in enumerate(options):
289
+ letter = chr(ord("a") + i)
290
+ if isinstance(opt, dict):
291
+ opt_id = opt.get("id", "")
292
+ label = opt.get("label", opt_id)
293
+ else:
294
+ label = str(opt)
295
+ parts.append(f"**{letter}**) {label}")
296
+ return " ".join(parts)
297
+
298
+
299
+ def _now_date() -> str:
300
+ from datetime import datetime, timezone
301
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
@@ -0,0 +1,145 @@
1
+ """E.164 normalization for CUCM Directory Numbers.
2
+
3
+ Classifies DNs as EXTENSION / NATIONAL / E164 / AMBIGUOUS and normalizes
4
+ to E.164 format using Google's phonenumbers library (libphonenumber).
5
+
6
+ Used by line_mapper during normalization pass 2 to produce CanonicalLine
7
+ objects with both bare extension and E.164 phone number fields.
8
+
9
+ (from 02-normalization-architecture.md, E.164 normalization section)
10
+ (from 03b-transform-mappers.md, line_mapper E.164 normalization algorithm)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ import phonenumbers
18
+
19
+
20
+ @dataclass
21
+ class E164Result:
22
+ """Result of E.164 normalization for a single DN.
23
+
24
+ Fields match what line_mapper in 03b-transform-mappers.md expects:
25
+ - e164: normalized E.164 string (e.g., "+14155551234") or None
26
+ - extension: bare extension digits (e.g., "1001") or None
27
+ - raw: the original input DN string
28
+ - classification: one of EXTENSION, NATIONAL, E164, AMBIGUOUS
29
+ """
30
+
31
+ e164: str | None
32
+ extension: str | None
33
+ raw: str
34
+ classification: str # EXTENSION | NATIONAL | E164 | AMBIGUOUS
35
+
36
+
37
+ # Maximum digits for a DN to be classified as an extension.
38
+ # (from 02-normalization-architecture.md: "if len(stripped) <= 6")
39
+ # Extended to handle CUCM extensions up to 10 digits, but phonenumbers
40
+ # is tried first — only numbers that fail phonenumbers parsing fall back
41
+ # to extension classification.
42
+ _MAX_EXTENSION_DIGITS = 6
43
+
44
+
45
+ def apply_prefix_rules(dn: str, rules: list[dict]) -> str:
46
+ """Strip a site-specific prefix from a DN.
47
+
48
+ Rules are applied in order; the first matching rule is used.
49
+ Each rule is a dict with "prefix" (str) and optionally "description".
50
+
51
+ (from 02-normalization-architecture.md: "Step 1: Strip site prefix")
52
+ (from 03b-transform-mappers.md, line_mapper field table: site prefix rules)
53
+ """
54
+ for rule in rules:
55
+ prefix = rule["prefix"]
56
+ if dn.startswith(prefix) and len(dn) > len(prefix):
57
+ return dn[len(prefix) :]
58
+ return dn
59
+
60
+
61
+ def normalize_dn(
62
+ dn: str,
63
+ country_code: str,
64
+ site_prefix_rules: list[dict],
65
+ ) -> E164Result:
66
+ """Normalize a CUCM DN to E.164 using libphonenumber.
67
+
68
+ (from 02-normalization-architecture.md, normalize_dn function)
69
+ (from 03b-transform-mappers.md, line_mapper E.164 normalization algorithm)
70
+
71
+ Args:
72
+ dn: Raw directory number string from CUCM.
73
+ country_code: ISO 3166-1 alpha-2 code (e.g., "US", "GB", "DE").
74
+ Comes from the DN's resolved location.
75
+ site_prefix_rules: List of prefix stripping rules from migration config.
76
+
77
+ Returns:
78
+ E164Result with classification and normalized values.
79
+ Invalid numbers produce AMBIGUOUS, never exceptions.
80
+ """
81
+ # Guard: empty or whitespace-only DN
82
+ if not dn or not dn.strip():
83
+ return E164Result(
84
+ e164=None,
85
+ extension=None,
86
+ raw=dn or "",
87
+ classification="AMBIGUOUS",
88
+ )
89
+
90
+ # Guard: non-digit characters that aren't '+' indicate special codes
91
+ if not dn[0].isdigit() and dn[0] != "+":
92
+ return E164Result(
93
+ e164=None,
94
+ extension=None,
95
+ raw=dn,
96
+ classification="AMBIGUOUS",
97
+ )
98
+
99
+ # Step 1: Strip site prefix (from 02-normalization-architecture.md)
100
+ stripped = apply_prefix_rules(dn, site_prefix_rules)
101
+
102
+ # Step 2: Try to parse as a phone number using phonenumbers
103
+ # (from 02-normalization-architecture.md: phonenumbers library usage)
104
+ try:
105
+ parsed = phonenumbers.parse(stripped, country_code)
106
+ if phonenumbers.is_valid_number(parsed):
107
+ e164 = phonenumbers.format_number(
108
+ parsed, phonenumbers.PhoneNumberFormat.E164
109
+ )
110
+ # Classify: already had '+' → E164, otherwise NATIONAL.
111
+ # Design doc (02-normalization-architecture.md:162) uses
112
+ # len(stripped) <= 10 heuristic; we use '+' prefix detection
113
+ # instead, which is more accurate for 11-digit national numbers
114
+ # like "14155551234" that shouldn't be classified as E164.
115
+ if stripped.startswith("+"):
116
+ classification = "E164"
117
+ else:
118
+ classification = "NATIONAL"
119
+ return E164Result(
120
+ e164=e164,
121
+ extension=None,
122
+ raw=dn,
123
+ classification=classification,
124
+ )
125
+ except phonenumbers.NumberParseException:
126
+ pass
127
+
128
+ # Step 3: Short number = extension
129
+ # (from 02-normalization-architecture.md: "if len(stripped) <= 6")
130
+ digits_only = stripped.replace("+", "")
131
+ if digits_only.isdigit() and len(digits_only) <= _MAX_EXTENSION_DIGITS:
132
+ return E164Result(
133
+ e164=None,
134
+ extension=stripped,
135
+ raw=dn,
136
+ classification="EXTENSION",
137
+ )
138
+
139
+ # Step 4: Can't classify
140
+ return E164Result(
141
+ e164=None,
142
+ extension=None,
143
+ raw=dn,
144
+ classification="AMBIGUOUS",
145
+ )