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,821 @@
1
+ """Routing mapper: CUCM routing infrastructure -> Webex Calling routing.
2
+
3
+ Maps CUCM SIP Trunks/Gateways, Route Groups, Route Patterns (-> Dial Plans),
4
+ and Translation Patterns to Webex Calling routing objects.
5
+
6
+ (from 03b-transform-mappers.md §6, routing_mapper)
7
+
8
+ Cross-ref reads:
9
+ route_pattern_uses_gateway (#11) — MAY be empty (listRoutePattern limitation)
10
+ route_pattern_uses_route_list (#12) — MAY be empty (listRoutePattern limitation)
11
+ route_group_has_trunk (#13) — route group membership
12
+ trunk_at_location (#14) — trunk -> device pool for location
13
+ translation_pattern_in_partition (#15) — translation pattern scoping
14
+
15
+ Decisions generated:
16
+ FEATURE_APPROXIMATION — @ macro in route pattern
17
+ LOCATION_AMBIGUOUS — trunk has no device pool; multiple locations available
18
+ MISSING_DATA — trunk password, missing address, partition scope issues
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import secrets
25
+ import string
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ from wxcli.migration.models import (
30
+ CanonicalDialPlan,
31
+ CanonicalRouteGroup,
32
+ CanonicalRouteList,
33
+ CanonicalTranslationPattern,
34
+ CanonicalTrunk,
35
+ DecisionOption,
36
+ DecisionType,
37
+ MapperResult,
38
+ MigrationStatus,
39
+ Provenance,
40
+ TrunkGatewayRef,
41
+ )
42
+ from wxcli.migration.store import MigrationStore
43
+ from wxcli.migration.transform.mappers.base import (
44
+ Mapper,
45
+ accept_option,
46
+ decision_to_store_dict,
47
+ extract_provenance,
48
+ hash_id,
49
+ manual_option,
50
+ skip_option,
51
+ )
52
+ from wxcli.migration.transform.pattern_converter import cucm_to_webex_pattern
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Max trunks per Webex route group
57
+ # (from 03b-transform-mappers.md §6: "Max 10 trunks per route group in Webex")
58
+ _MAX_TRUNKS_PER_ROUTE_GROUP = 10
59
+
60
+ # Temporary password length for trunk authentication
61
+ _TRUNK_PASSWORD_LENGTH = 16
62
+
63
+
64
+ def _bool_field(val: Any) -> bool | None:
65
+ """Coerce an AXL field to bool or None."""
66
+ if val is None:
67
+ return None
68
+ if isinstance(val, bool):
69
+ return val
70
+ s = str(val).lower()
71
+ if s in ("true", "t", "1"):
72
+ return True
73
+ if s in ("false", "f", "0"):
74
+ return False
75
+ return None
76
+
77
+
78
+ def _generate_temp_password() -> str:
79
+ """Generate a temporary password for trunk authentication.
80
+
81
+ (from 03b-transform-mappers.md §6: "mapper generates a temporary password")
82
+ """
83
+ alphabet = string.ascii_letters + string.digits + "!@#$%"
84
+ return "".join(secrets.choice(alphabet) for _ in range(_TRUNK_PASSWORD_LENGTH))
85
+
86
+
87
+ class RoutingMapper(Mapper):
88
+ """Maps CUCM routing infrastructure to Webex Calling routing objects.
89
+
90
+ Produces: CanonicalTrunk, CanonicalRouteGroup, CanonicalDialPlan,
91
+ CanonicalTranslationPattern.
92
+
93
+ (from 03b-transform-mappers.md §6)
94
+ """
95
+
96
+ name = "routing_mapper"
97
+ depends_on = ["location_mapper"]
98
+
99
+ def __init__(
100
+ self,
101
+ country_code: str = "+1",
102
+ outside_dial_digit: str = "9",
103
+ ) -> None:
104
+ self.country_code = country_code
105
+ self.outside_dial_digit = outside_dial_digit
106
+
107
+ def map(self, store: MigrationStore) -> MapperResult:
108
+ """Read routing objects and produce Webex routing canonical objects."""
109
+ result = MapperResult()
110
+
111
+ self._map_trunks(store, result)
112
+ self._map_route_groups(store, result)
113
+ self._map_route_lists(store, result)
114
+ self._map_dial_plans(store, result)
115
+ self._map_translation_patterns(store, result)
116
+
117
+ return result
118
+
119
+ # ------------------------------------------------------------------
120
+ # Trunks: CUCM SIP Trunk / Gateway -> CanonicalTrunk
121
+ # ------------------------------------------------------------------
122
+
123
+ def _map_trunks(self, store: MigrationStore, result: MapperResult) -> None:
124
+ """Map CUCM SIP trunks and gateways to CanonicalTrunk objects.
125
+
126
+ (from 03b-transform-mappers.md §6: trunk field mapping table)
127
+ """
128
+ for trunk_type in ("sip_trunk", "gateway"):
129
+ for trunk_data in store.get_objects(trunk_type):
130
+ trunk_id = trunk_data["canonical_id"]
131
+ state = trunk_data.get("pre_migration_state") or {}
132
+
133
+ name = state.get("name") or trunk_id.split(":", 1)[-1]
134
+
135
+ # --- Location resolution ---
136
+ # Chain: trunk -> trunk_at_location -> device_pool -> device_pool_to_location
137
+ # (from 03b-transform-mappers.md §6: "trunk_at_location cross-ref")
138
+ location_id = store.resolve_chain(
139
+ trunk_id, "trunk_at_location", "device_pool_to_location"
140
+ )
141
+
142
+ # --- Trunk type inference ---
143
+ # (from 03b-transform-mappers.md §6: "SIP profile + security profile -> type")
144
+ trunk_type_val = _infer_trunk_type(state)
145
+
146
+ # --- Destination address ---
147
+ # Phase 04 correction: destinations is a LIST of [{address, port, sort_order}]
148
+ # Use primary destination (sort_order 1 or first in list)
149
+ address = None
150
+ port = None
151
+ destinations = state.get("destinations", [])
152
+ if isinstance(destinations, list) and destinations:
153
+ # Find primary destination (sort_order 1 or first)
154
+ primary = destinations[0] or {}
155
+ for dest in destinations:
156
+ if not dest:
157
+ continue
158
+ if dest.get("sort_order") == 1:
159
+ primary = dest
160
+ break
161
+ address = primary.get("address")
162
+ port = primary.get("port")
163
+ elif isinstance(destinations, dict):
164
+ # Legacy flat format fallback
165
+ address = destinations.get("address") or state.get("destination_address")
166
+ port = destinations.get("port") or state.get("destination_port")
167
+ else:
168
+ # Try flat fields as last resort
169
+ address = state.get("destination_address")
170
+ port = state.get("destination_port")
171
+
172
+ if port is not None:
173
+ try:
174
+ port = int(port)
175
+ except (ValueError, TypeError):
176
+ port = None
177
+
178
+ # --- Password generation ---
179
+ # (from 03b-transform-mappers.md §6: "mapper generates temporary password")
180
+ password = _generate_temp_password()
181
+
182
+ # --- Max concurrent calls ---
183
+ max_concurrent_calls = state.get("max_calls") or state.get("maxCallsPerSIPTrunk")
184
+ if max_concurrent_calls is not None:
185
+ try:
186
+ max_concurrent_calls = int(max_concurrent_calls)
187
+ except (ValueError, TypeError):
188
+ max_concurrent_calls = None
189
+
190
+ # --- Other fields ---
191
+ dual_identity = state.get("dualIdentitySupportEnabled")
192
+ device_type = state.get("device_type")
193
+ p_charge_info = state.get("pChargeInfoSupportPolicy")
194
+
195
+ # Fix 10: Resolve location when cross-ref chain breaks (org-wide trunks)
196
+ if not location_id:
197
+ locations = store.get_objects("location")
198
+ if len(locations) == 1:
199
+ # Single location — auto-assign (no decision needed)
200
+ location_id = locations[0]["canonical_id"]
201
+ logger.info(
202
+ "Trunk '%s' auto-assigned to only location '%s'",
203
+ name, location_id,
204
+ )
205
+ elif len(locations) > 1:
206
+ # Multiple locations — present as selectable options
207
+ loc_options = [
208
+ DecisionOption(
209
+ id=loc["canonical_id"],
210
+ label=f"Assign to {(loc.get('pre_migration_state') or {}).get('name', loc['canonical_id'])}",
211
+ impact=f"Trunk created in {(loc.get('pre_migration_state') or {}).get('name', loc['canonical_id'])}",
212
+ )
213
+ for loc in locations
214
+ ]
215
+ loc_options.append(skip_option("Trunk not migrated"))
216
+ decision = self._create_decision(
217
+ store=store,
218
+ decision_type=DecisionType.LOCATION_AMBIGUOUS,
219
+ severity="HIGH",
220
+ summary=(
221
+ f"Trunk '{name}' has no device pool — "
222
+ f"choose which of {len(locations)} locations to assign it to"
223
+ ),
224
+ context={
225
+ "trunk_id": f"trunk:{name}",
226
+ "trunk_name": name,
227
+ "reason": "no_location",
228
+ "available_locations": [
229
+ loc["canonical_id"] for loc in locations
230
+ ],
231
+ },
232
+ options=loc_options,
233
+ affected_objects=[f"trunk:{name}"],
234
+ )
235
+ store.save_decision(decision_to_store_dict(decision))
236
+ result.decisions.append(decision)
237
+ else:
238
+ # No locations at all — MISSING_DATA
239
+ decision = self._create_decision(
240
+ store=store,
241
+ decision_type=DecisionType.MISSING_DATA,
242
+ severity="HIGH",
243
+ summary=(
244
+ f"Trunk '{name}' has no resolvable location and "
245
+ f"no locations exist in migration — "
246
+ f"locationId required for Webex trunk creation"
247
+ ),
248
+ context={
249
+ "trunk_id": f"trunk:{name}",
250
+ "trunk_name": name,
251
+ "reason": "no_location",
252
+ },
253
+ options=[
254
+ manual_option("Admin assigns trunk to a Webex location"),
255
+ skip_option("Trunk not migrated"),
256
+ ],
257
+ affected_objects=[f"trunk:{name}"],
258
+ )
259
+ store.save_decision(decision_to_store_dict(decision))
260
+ result.decisions.append(decision)
261
+
262
+ trunk = CanonicalTrunk(
263
+ canonical_id=f"trunk:{name}",
264
+ provenance=extract_provenance(trunk_data),
265
+ status=MigrationStatus.ANALYZED,
266
+ name=name,
267
+ location_id=location_id,
268
+ trunk_type=trunk_type_val,
269
+ address=address,
270
+ domain=state.get("domain"),
271
+ port=port,
272
+ max_concurrent_calls=max_concurrent_calls,
273
+ password=password,
274
+ dual_identity_support_enabled=dual_identity,
275
+ device_type=device_type,
276
+ p_charge_info_support_policy=p_charge_info,
277
+ # Tier 2 §2.8: SIP/security profile detail
278
+ sip_profile_early_offer=_bool_field(state.get("sip_profile_early_offer")),
279
+ sip_profile_srtp_fallback=_bool_field(state.get("sip_profile_srtp_fallback")),
280
+ security_mode=state.get("security_mode"),
281
+ security_digest_auth=_bool_field(state.get("security_digest_auth")),
282
+ )
283
+
284
+ store.upsert_object(trunk)
285
+ result.objects_created += 1
286
+
287
+ # --- MISSING_DATA decision for password ---
288
+ # (from 03b-transform-mappers.md §6: "trunk password can't be extracted")
289
+ decision = self._create_decision(
290
+ store=store,
291
+ decision_type=DecisionType.MISSING_DATA,
292
+ severity="MEDIUM",
293
+ summary=(
294
+ f"Trunk '{name}' password cannot be extracted from CUCM — "
295
+ f"temporary password generated"
296
+ ),
297
+ context={
298
+ "trunk_id": trunk.canonical_id,
299
+ "trunk_name": name,
300
+ "reason": "password_not_extractable",
301
+ },
302
+ options=[
303
+ manual_option(
304
+ "Admin configures matching passwords on gateway and in Webex"
305
+ ),
306
+ accept_option(
307
+ "Accept generated password and update gateway configuration"
308
+ ),
309
+ ],
310
+ affected_objects=[trunk.canonical_id],
311
+ )
312
+ store.save_decision(decision_to_store_dict(decision))
313
+ result.decisions.append(decision)
314
+
315
+ # --- MISSING_DATA decision for missing address ---
316
+ if not address and trunk_type_val == "CERTIFICATE_BASED":
317
+ decision = self._create_decision(
318
+ store=store,
319
+ decision_type=DecisionType.MISSING_DATA,
320
+ severity="HIGH",
321
+ summary=(
322
+ f"Trunk '{name}' has no destination address — "
323
+ f"required for CERTIFICATE_BASED trunk type"
324
+ ),
325
+ context={
326
+ "trunk_id": trunk.canonical_id,
327
+ "trunk_name": name,
328
+ "reason": "missing_address",
329
+ },
330
+ options=[
331
+ manual_option("Admin provides SBC/gateway FQDN or SRV record"),
332
+ skip_option("Skip trunk — routing through this trunk won't be migrated"),
333
+ ],
334
+ affected_objects=[trunk.canonical_id],
335
+ )
336
+ store.save_decision(decision_to_store_dict(decision))
337
+ result.decisions.append(decision)
338
+
339
+ # ------------------------------------------------------------------
340
+ # Route Groups: CUCM Route Group -> CanonicalRouteGroup
341
+ # ------------------------------------------------------------------
342
+
343
+ def _map_route_groups(self, store: MigrationStore, result: MapperResult) -> None:
344
+ """Map CUCM route groups to CanonicalRouteGroup objects.
345
+
346
+ Splits groups exceeding 10-trunk limit into multiple Webex route groups.
347
+
348
+ (from 03b-transform-mappers.md §6: route group field mapping table)
349
+ """
350
+ for rg_data in store.get_objects("route_group"):
351
+ rg_id = rg_data["canonical_id"]
352
+ state = rg_data.get("pre_migration_state") or {}
353
+
354
+ rg_name = state.get("name") or rg_id.split(":", 1)[-1]
355
+
356
+ # --- Resolve trunks via cross-ref ---
357
+ # (from 03b-transform-mappers.md §6: "route_group_has_trunk")
358
+ trunk_refs = store.find_cross_refs(rg_id, "route_group_has_trunk")
359
+
360
+ # Build trunk gateway refs with priority from pre_migration_state
361
+ trunks_with_priority = []
362
+ state_trunks = state.get("trunks") or []
363
+ for i, trunk_ref_id in enumerate(trunk_refs):
364
+ # Try to find priority from state data
365
+ priority = i + 1
366
+ trunk_name = trunk_ref_id.split(":", 1)[-1] if ":" in trunk_ref_id else trunk_ref_id
367
+ for st in state_trunks:
368
+ if not st:
369
+ continue
370
+ if st.get("trunk_name") == trunk_name:
371
+ priority = st.get("priority", i + 1)
372
+ break
373
+
374
+ # Resolve to the canonical trunk ID (trunk:name format)
375
+ canonical_trunk_id = f"trunk:{trunk_name}"
376
+ trunks_with_priority.append(
377
+ TrunkGatewayRef(trunk_canonical_id=canonical_trunk_id, priority=priority)
378
+ )
379
+
380
+ # Fix 11: MISSING_DATA decision if no gateways resolved
381
+ if not trunks_with_priority:
382
+ decision = self._create_decision(
383
+ store=store,
384
+ decision_type=DecisionType.MISSING_DATA,
385
+ severity="HIGH",
386
+ summary=(
387
+ f"Route group '{rg_name}' has no resolvable gateway members — "
388
+ f"at least one localGateway required"
389
+ ),
390
+ context={
391
+ "route_group_id": f"route_group:{rg_name}",
392
+ "route_group_name": rg_name,
393
+ "reason": "no_gateways",
394
+ },
395
+ options=[
396
+ manual_option("Admin assigns trunk/gateway members"),
397
+ skip_option("Route group not migrated"),
398
+ ],
399
+ affected_objects=[f"route_group:{rg_name}"],
400
+ )
401
+ store.save_decision(decision_to_store_dict(decision))
402
+ result.decisions.append(decision)
403
+
404
+ # --- Split if exceeding max trunks ---
405
+ # (from 03b-transform-mappers.md §6: "Max 10 trunks per route group")
406
+ if len(trunks_with_priority) <= _MAX_TRUNKS_PER_ROUTE_GROUP:
407
+ rg = CanonicalRouteGroup(
408
+ canonical_id=f"route_group:{rg_name}",
409
+ provenance=extract_provenance(rg_data),
410
+ status=MigrationStatus.ANALYZED,
411
+ name=rg_name,
412
+ local_gateways=trunks_with_priority,
413
+ )
414
+ store.upsert_object(rg)
415
+ result.objects_created += 1
416
+ else:
417
+ # Split into chunks of max 10
418
+ for chunk_idx, start in enumerate(
419
+ range(0, len(trunks_with_priority), _MAX_TRUNKS_PER_ROUTE_GROUP)
420
+ ):
421
+ chunk = trunks_with_priority[start:start + _MAX_TRUNKS_PER_ROUTE_GROUP]
422
+ split_name = f"{rg_name}-{chunk_idx + 1}"
423
+ rg = CanonicalRouteGroup(
424
+ canonical_id=f"route_group:{split_name}",
425
+ provenance=extract_provenance(rg_data),
426
+ status=MigrationStatus.ANALYZED,
427
+ name=split_name,
428
+ local_gateways=chunk,
429
+ )
430
+ store.upsert_object(rg)
431
+ result.objects_created += 1
432
+
433
+ # ------------------------------------------------------------------
434
+ # Route Lists: CUCM Route List -> CanonicalRouteList
435
+ # ------------------------------------------------------------------
436
+
437
+ def _map_route_lists(self, store: MigrationStore, result: MapperResult) -> None:
438
+ """Map CUCM route lists to CanonicalRouteList objects.
439
+
440
+ Webex route lists bind to exactly ONE route group. CUCM route lists with
441
+ multiple route group members produce a FEATURE_APPROXIMATION decision.
442
+ Route lists are standalone resources — dial plans point to route groups,
443
+ not route lists (RouteType enum = ROUTE_GROUP | TRUNK only).
444
+ """
445
+ for rl_data in store.get_objects("route_list"):
446
+ if rl_data.get("status") == "analyzed":
447
+ continue # Already mapped (e.g., re-run)
448
+
449
+ rl_id = rl_data["canonical_id"]
450
+ state = rl_data.get("pre_migration_state") or {}
451
+ rl_name = state.get("route_list_name") or rl_id.split(":", 1)[-1]
452
+
453
+ # Skip disabled route lists
454
+ enabled = state.get("routeListEnabled", "true")
455
+ if str(enabled).lower() == "false":
456
+ continue
457
+
458
+ route_groups = state.get("route_groups") or []
459
+
460
+ # No route group members -> MISSING_DATA
461
+ if not route_groups:
462
+ decision = self._create_decision(
463
+ store=store,
464
+ decision_type=DecisionType.MISSING_DATA,
465
+ severity="HIGH",
466
+ summary=(
467
+ f"Route list '{rl_name}' has no route group members — "
468
+ f"at least one route group required"
469
+ ),
470
+ context={
471
+ "route_list_id": rl_id,
472
+ "route_list_name": rl_name,
473
+ "reason": "route_list_no_members",
474
+ },
475
+ options=[
476
+ manual_option("Admin assigns route group member"),
477
+ skip_option("Route list not migrated"),
478
+ ],
479
+ affected_objects=[rl_id],
480
+ )
481
+ store.save_decision(decision_to_store_dict(decision))
482
+ result.decisions.append(decision)
483
+ continue
484
+
485
+ # Single route group -> direct mapping
486
+ if len(route_groups) == 1:
487
+ rg_name = route_groups[0]
488
+ rl = CanonicalRouteList(
489
+ canonical_id=rl_id,
490
+ provenance=extract_provenance(rl_data),
491
+ status=MigrationStatus.ANALYZED,
492
+ name=rl_name,
493
+ route_group_id=f"route_group:{rg_name}",
494
+ cucm_route_list_name=rl_name,
495
+ cucm_route_groups=route_groups,
496
+ )
497
+ store.upsert_object(rl)
498
+ result.objects_created += 1
499
+ else:
500
+ # Multiple route groups -> FEATURE_APPROXIMATION decision
501
+ # Webex route lists bind to exactly ONE route group
502
+ decision = self._create_decision(
503
+ store=store,
504
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
505
+ severity="MEDIUM",
506
+ summary=(
507
+ f"Route list '{rl_name}' has {len(route_groups)} route group members "
508
+ f"({', '.join(route_groups)}). Webex route lists bind to exactly one "
509
+ f"route group — choose how to handle the extra members."
510
+ ),
511
+ context={
512
+ "route_list_id": rl_id,
513
+ "route_list_name": rl_name,
514
+ "route_groups": route_groups,
515
+ "reason": "multi_route_group_route_list",
516
+ },
517
+ options=[
518
+ accept_option(
519
+ f"Split into {len(route_groups)} route lists "
520
+ f"(one per route group)"
521
+ ),
522
+ accept_option(
523
+ f"Use first route group only ({route_groups[0]}) — "
524
+ f"loses failover to other members"
525
+ ),
526
+ skip_option("Route list not migrated"),
527
+ ],
528
+ affected_objects=[rl_id],
529
+ )
530
+ store.save_decision(decision_to_store_dict(decision))
531
+ result.decisions.append(decision)
532
+
533
+ # Default: create with first route group (can be overridden by decision)
534
+ rl = CanonicalRouteList(
535
+ canonical_id=rl_id,
536
+ provenance=extract_provenance(rl_data),
537
+ status=MigrationStatus.ANALYZED,
538
+ name=rl_name,
539
+ route_group_id=f"route_group:{route_groups[0]}",
540
+ cucm_route_list_name=rl_name,
541
+ cucm_route_groups=route_groups,
542
+ )
543
+ store.upsert_object(rl)
544
+ result.objects_created += 1
545
+
546
+ # ------------------------------------------------------------------
547
+ # Dial Plans: CUCM Route Patterns -> CanonicalDialPlan
548
+ # ------------------------------------------------------------------
549
+
550
+ def _map_dial_plans(self, store: MigrationStore, result: MapperResult) -> None:
551
+ """Map CUCM route patterns to CanonicalDialPlan objects.
552
+
553
+ Groups route patterns by their routing target (gateway/trunk or route group)
554
+ into consolidated dial plans.
555
+
556
+ Phase 04 correction: route_pattern_uses_gateway (#11) and
557
+ route_pattern_uses_route_list (#12) cross-refs may be EMPTY because
558
+ listRoutePattern doesn't return destination data. Must fall back to
559
+ pre_migration_state if available.
560
+
561
+ (from 03b-transform-mappers.md §6: dial plan field mapping table)
562
+ """
563
+ # Group patterns by target for consolidation
564
+ # Key = target canonical_id (trunk:X or route_group:X), Value = list of converted patterns
565
+ target_groups: dict[str, list[str]] = {}
566
+ # Track route pattern names for dial plan naming
567
+ target_names: dict[str, str] = {}
568
+ # Track route types per target
569
+ target_route_types: dict[str, str] = {}
570
+
571
+ rp_count = 0
572
+ skipped_at_macro = 0
573
+ skipped_no_target = 0
574
+
575
+ for rp_data in store.get_objects("route_pattern"):
576
+ rp_id = rp_data["canonical_id"]
577
+ state = rp_data.get("pre_migration_state") or {}
578
+
579
+ cucm_pattern = state.get("pattern", "")
580
+
581
+ # --- @ macro detection ---
582
+ # (from 03b-transform-mappers.md §6: "@ macro requires expansion")
583
+ if "@" in cucm_pattern:
584
+ decision = self._create_decision(
585
+ store=store,
586
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
587
+ severity="MEDIUM",
588
+ summary=(
589
+ f"Route pattern '{cucm_pattern}' uses @ macro (national numbering "
590
+ f"plan) which has no single Webex equivalent"
591
+ ),
592
+ context={
593
+ "route_pattern_id": rp_id,
594
+ "cucm_pattern": cucm_pattern,
595
+ "reason": "at_macro",
596
+ },
597
+ options=[
598
+ accept_option("Expand to explicit pattern set from national plan"),
599
+ accept_option("Use broad wildcard +! as approximation"),
600
+ manual_option("Admin defines explicit patterns manually"),
601
+ ],
602
+ affected_objects=[rp_id],
603
+ )
604
+ store.save_decision(decision_to_store_dict(decision))
605
+ result.decisions.append(decision)
606
+ # Skip this pattern — can't convert automatically
607
+ skipped_at_macro += 1
608
+ continue
609
+
610
+ # --- Convert pattern ---
611
+ webex_pattern = cucm_to_webex_pattern(
612
+ cucm_pattern, self.country_code, self.outside_dial_digit
613
+ )
614
+
615
+ # --- Resolve routing target ---
616
+ # Try cross-refs first, then fall back to pre_migration_state
617
+ # Phase 04 correction: cross-refs may be empty
618
+ target_id = None
619
+ route_type = None
620
+
621
+ # Try gateway cross-ref (#11)
622
+ gw_refs = store.find_cross_refs(rp_id, "route_pattern_uses_gateway")
623
+ if gw_refs:
624
+ # Gateway -> trunk mapping
625
+ gw_id = gw_refs[0]
626
+ gw_name = gw_id.split(":", 1)[-1] if ":" in gw_id else gw_id
627
+ target_id = f"trunk:{gw_name}"
628
+ route_type = "TRUNK"
629
+ else:
630
+ # Try route list cross-ref (#12)
631
+ rl_refs = store.find_cross_refs(rp_id, "route_pattern_uses_route_list")
632
+ if rl_refs:
633
+ # Webex dial plans only support RouteType=ROUTE_GROUP|TRUNK,
634
+ # so we resolve through the mapped CanonicalRouteList to get
635
+ # its underlying route_group_id. The route list itself is
636
+ # created as a parallel standalone resource.
637
+ rl_id = rl_refs[0]
638
+ rl_obj = store.get_object(rl_id)
639
+ if rl_obj and rl_obj.get("route_group_id"):
640
+ target_id = rl_obj["route_group_id"]
641
+ route_type = "ROUTE_GROUP"
642
+
643
+ # Fall back to pre_migration_state if cross-refs were empty
644
+ if not target_id:
645
+ target_type = state.get("target_type")
646
+ target_name = state.get("target_name")
647
+ if target_name and target_type:
648
+ if target_type in ("gateway", "trunk"):
649
+ target_id = f"trunk:{target_name}"
650
+ route_type = "TRUNK"
651
+ elif target_type in ("route_list", "routeList", "route_group"):
652
+ target_id = f"route_group:{target_name}"
653
+ route_type = "ROUTE_GROUP"
654
+
655
+ if not target_id:
656
+ # No target resolved — create MISSING_DATA decision
657
+ decision = self._create_decision(
658
+ store=store,
659
+ decision_type=DecisionType.MISSING_DATA,
660
+ severity="MEDIUM",
661
+ summary=(
662
+ f"Route pattern '{cucm_pattern}' has no resolvable routing "
663
+ f"target — cross-refs and pre_migration_state both empty"
664
+ ),
665
+ context={
666
+ "route_pattern_id": rp_id,
667
+ "cucm_pattern": cucm_pattern,
668
+ "reason": "no_routing_target",
669
+ },
670
+ options=[
671
+ manual_option("Admin assigns routing target manually"),
672
+ skip_option("Pattern not migrated"),
673
+ ],
674
+ affected_objects=[rp_id],
675
+ )
676
+ store.save_decision(decision_to_store_dict(decision))
677
+ result.decisions.append(decision)
678
+ skipped_no_target += 1
679
+ continue
680
+
681
+ # Accumulate pattern into target group
682
+ target_groups.setdefault(target_id, []).append(webex_pattern)
683
+ if target_id not in target_names:
684
+ target_names[target_id] = target_id.split(":", 1)[-1]
685
+ target_route_types[target_id] = route_type
686
+ rp_count += 1
687
+
688
+ logger.info(
689
+ "Dial plan mapping: %d route patterns processed, %d skipped (@ macro), "
690
+ "%d skipped (no target), %d dial plans created",
691
+ rp_count, skipped_at_macro, skipped_no_target, len(target_groups),
692
+ )
693
+
694
+ # --- Create CanonicalDialPlan per target ---
695
+ for target_id, patterns in target_groups.items():
696
+ target_name = target_names[target_id]
697
+ route_type = target_route_types[target_id]
698
+
699
+ dp_name = f"Migration-DialPlan-{target_name}"
700
+
701
+ dial_plan = CanonicalDialPlan(
702
+ canonical_id=f"dial_plan:{hash_id(dp_name)}",
703
+ provenance=Provenance(
704
+ source_system="cucm",
705
+ source_id=target_id,
706
+ source_name=dp_name,
707
+ extracted_at=datetime.now(timezone.utc),
708
+ ),
709
+ status=MigrationStatus.ANALYZED,
710
+ name=dp_name,
711
+ dial_patterns=sorted(set(patterns)),
712
+ route_id=target_id,
713
+ route_type=route_type,
714
+ )
715
+
716
+ store.upsert_object(dial_plan)
717
+ result.objects_created += 1
718
+
719
+ # ------------------------------------------------------------------
720
+ # Translation Patterns
721
+ # ------------------------------------------------------------------
722
+
723
+ def _map_translation_patterns(
724
+ self, store: MigrationStore, result: MapperResult
725
+ ) -> None:
726
+ """Map CUCM translation patterns to CanonicalTranslationPattern objects.
727
+
728
+ (from 03b-transform-mappers.md §6: translation pattern field mapping table)
729
+ """
730
+ tp_objects = list(store.get_objects("translation_pattern"))
731
+ logger.info("Translation pattern mapping: %d source objects found", len(tp_objects))
732
+
733
+ for tp_data in tp_objects:
734
+ tp_id = tp_data["canonical_id"]
735
+ state = tp_data.get("pre_migration_state") or {}
736
+
737
+ cucm_pattern = state.get("pattern", "")
738
+
739
+ # Fix 6: Skip translation patterns with empty/None pattern
740
+ if not cucm_pattern:
741
+ continue
742
+ replacement = state.get("calledPartyTransformationMask", "")
743
+ description = state.get("description") or cucm_pattern
744
+
745
+ # --- Convert matching pattern ---
746
+ matching_pattern = cucm_to_webex_pattern(
747
+ cucm_pattern, self.country_code, self.outside_dial_digit
748
+ )
749
+
750
+ # --- Validate replacement pattern ---
751
+ # (from 03b-transform-mappers.md §6: "E.164 replacementPattern cannot contain X wildcards")
752
+ replacement_pattern = replacement
753
+ if replacement_pattern and replacement_pattern.startswith("+") and "X" in replacement_pattern.upper():
754
+ decision = self._create_decision(
755
+ store=store,
756
+ decision_type=DecisionType.MISSING_DATA,
757
+ severity="MEDIUM",
758
+ summary=(
759
+ f"Translation pattern '{cucm_pattern}' has E.164 replacement "
760
+ f"'{replacement}' with X wildcards — invalid in Webex"
761
+ ),
762
+ context={
763
+ "translation_pattern_id": tp_id,
764
+ "cucm_pattern": cucm_pattern,
765
+ "replacement_pattern": replacement,
766
+ "reason": "e164_replacement_has_wildcards",
767
+ },
768
+ options=[
769
+ manual_option("Admin provides valid replacement pattern without wildcards"),
770
+ skip_option("Translation pattern not migrated"),
771
+ ],
772
+ affected_objects=[tp_id],
773
+ )
774
+ store.save_decision(decision_to_store_dict(decision))
775
+ result.decisions.append(decision)
776
+
777
+ tp_obj = CanonicalTranslationPattern(
778
+ canonical_id=f"translation_pattern:{hash_id(cucm_pattern)}",
779
+ provenance=extract_provenance(tp_data),
780
+ status=MigrationStatus.ANALYZED,
781
+ name=description,
782
+ matching_pattern=matching_pattern,
783
+ replacement_pattern=replacement_pattern,
784
+ )
785
+
786
+ store.upsert_object(tp_obj)
787
+ result.objects_created += 1
788
+
789
+
790
+ # ---------------------------------------------------------------------------
791
+ # Helpers
792
+ # ---------------------------------------------------------------------------
793
+
794
+
795
+ def _infer_trunk_type(state: dict[str, Any]) -> str:
796
+ """Infer Webex trunk type from CUCM SIP trunk settings.
797
+
798
+ (from 03b-transform-mappers.md §6: "REGISTERING or CERTIFICATE_BASED")
799
+ Tier 2: Prefer explicit security_mode from profile detail when available.
800
+ """
801
+ # Tier 2: Prefer explicit security_mode from profile detail
802
+ security_mode = (state.get("security_mode") or "").upper()
803
+ if security_mode == "TLS":
804
+ return "CERTIFICATE_BASED"
805
+ if security_mode == "IPSEC":
806
+ return "CERTIFICATE_BASED" # Closest Webex equivalent
807
+ if security_mode == "NON_SECURE":
808
+ return "REGISTERING"
809
+
810
+ # Fallback: pattern-match on security profile name
811
+ security_profile = (state.get("security_profile") or state.get("securityProfileName") or "").lower()
812
+ if "tls" in security_profile or "certificate" in security_profile:
813
+ return "CERTIFICATE_BASED"
814
+
815
+ trunk_type = state.get("trunk_type", "").upper()
816
+ if trunk_type == "CERTIFICATE_BASED":
817
+ return "CERTIFICATE_BASED"
818
+
819
+ return "REGISTERING"
820
+
821
+