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,868 @@
1
+ """CSS decomposition mapper: CUCM CSS -> Webex Dial Plans + Calling Permissions.
2
+
3
+ Decomposes CUCM Calling Search Spaces into:
4
+ - Webex Calling Dial Plans (org-wide routing)
5
+ - Per-user Outgoing Calling Permissions (category-based blocking)
6
+
7
+ Implements the 5-step algorithm from 04-css-decomposition.md.
8
+
9
+ (from 03b-transform-mappers.md §7, css_mapper)
10
+ (from 04-css-decomposition.md, full algorithm)
11
+
12
+ Cross-ref reads:
13
+ css_contains_partition (#16) — ordered partitions in a CSS
14
+ partition_has_pattern (#17) — route patterns in a partition
15
+ user_has_css (#18) — user -> CSS assignment
16
+ device_has_css (#19) — device -> CSS assignment
17
+ line_has_css (#20) — line -> CSS assignment
18
+
19
+ Decisions generated:
20
+ CSS_ROUTING_MISMATCH — routing scope differences, MIXED partitions, ordering conflicts
21
+ CALLING_PERMISSION_MISMATCH — unclassifiable block patterns
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from collections import defaultdict
28
+ from datetime import datetime, timezone
29
+ from typing import Any
30
+
31
+ from wxcli.migration.models import (
32
+ CallingPermissionEntry,
33
+ CanonicalCallingPermission,
34
+ CanonicalDialPlan,
35
+ DecisionType,
36
+ MapperResult,
37
+ MigrationStatus,
38
+ Provenance,
39
+ )
40
+ from wxcli.migration.store import MigrationStore
41
+ from wxcli.migration.transform.cucm_pattern import (
42
+ classify_block_pattern,
43
+ cucm_patterns_overlap,
44
+ )
45
+ from wxcli.migration.transform.mappers.base import (
46
+ Mapper,
47
+ accept_option,
48
+ decision_to_store_dict,
49
+ hash_id,
50
+ manual_option,
51
+ skip_option,
52
+ )
53
+ from wxcli.migration.transform.pattern_converter import cucm_to_webex_pattern
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Webex OutgoingPermissionCallType values
58
+ # (from docs/reference/person-call-settings-permissions.md)
59
+ WEBEX_CALL_TYPES = [
60
+ "INTERNAL_CALL",
61
+ "TOLL_FREE",
62
+ "NATIONAL",
63
+ "INTERNATIONAL",
64
+ "OPERATOR_ASSISTED",
65
+ "CHARGEABLE_DIRECTORY_ASSISTED",
66
+ "SPECIAL_SERVICES_I",
67
+ "SPECIAL_SERVICES_II",
68
+ "PREMIUM_SERVICES_I",
69
+ "PREMIUM_SERVICES_II",
70
+ ]
71
+
72
+ # Partition classification labels
73
+ # (from 04-css-decomposition.md Step 2)
74
+ DIRECTORY = "DIRECTORY"
75
+ ROUTING = "ROUTING"
76
+ BLOCKING = "BLOCKING"
77
+ MIXED = "MIXED"
78
+
79
+
80
+ class CSSMapper(Mapper):
81
+ """Decomposes CUCM CSSes into Webex Dial Plans and Calling Permissions.
82
+
83
+ Implements the 5-step CSS decomposition algorithm:
84
+ 1. Build CSS->Partition->Pattern graph
85
+ 2. Classify partitions (DIRECTORY/ROUTING/BLOCKING/MIXED)
86
+ 3. Compute routing scope per CSS, intersection baseline
87
+ 4. Compute restriction profiles, group users by profile
88
+ 5. Detect ordering conflicts via cucm_patterns_overlap()
89
+
90
+ (from 03b-transform-mappers.md §7, 04-css-decomposition.md)
91
+ """
92
+
93
+ name = "css_mapper"
94
+ depends_on = ["routing_mapper", "user_mapper", "line_mapper", "device_mapper"]
95
+
96
+ def __init__(
97
+ self,
98
+ category_rules: list[dict[str, str]] | None = None,
99
+ country_code: str = "+1",
100
+ outside_dial_digit: str = "9",
101
+ ) -> None:
102
+ """Initialize the CSS mapper.
103
+
104
+ Args:
105
+ category_rules: Configurable mapping from CUCM block patterns to
106
+ Webex permission categories. Each rule is a dict with
107
+ 'cucm_pattern' and 'webex_category' keys.
108
+ country_code: E.164 country prefix for pattern conversion.
109
+ outside_dial_digit: CUCM outside access digit.
110
+ """
111
+ self.category_rules = category_rules or []
112
+ self.country_code = country_code
113
+ self.outside_dial_digit = outside_dial_digit
114
+
115
+ def map(self, store: MigrationStore) -> MapperResult:
116
+ """Execute the CSS decomposition algorithm."""
117
+ result = MapperResult()
118
+
119
+ # Get all CSS objects
120
+ css_objects = store.get_objects("css")
121
+ if not css_objects:
122
+ logger.info("No CSS objects found — skipping CSS decomposition")
123
+ return result
124
+
125
+ # Step 1+2: Build CSS graph and classify partitions
126
+ css_graphs = {}
127
+ for css_data in css_objects:
128
+ css_id = css_data["canonical_id"]
129
+ graph = self._build_css_graph(store, css_id, result)
130
+ if graph:
131
+ css_graphs[css_id] = graph
132
+
133
+ if not css_graphs:
134
+ logger.info("No CSS objects with partitions found")
135
+ return result
136
+
137
+ # Build combined CSS for users (Line CSS + Device CSS)
138
+ user_effective_css = self._build_user_effective_css(store, css_graphs)
139
+
140
+ # Step 3: Compute routing scope per effective CSS, group by scope
141
+ self._compute_routing_scopes(store, css_graphs, user_effective_css, result)
142
+
143
+ # Step 4: Compute restriction profiles and create CallingPermissions
144
+ self._compute_restriction_profiles(
145
+ store, css_graphs, user_effective_css, result
146
+ )
147
+
148
+ # Step 5: Detect ordering conflicts
149
+ self._detect_ordering_conflicts(store, css_graphs, result)
150
+
151
+ return result
152
+
153
+ # ------------------------------------------------------------------
154
+ # Step 1+2: Build CSS→Partition→Pattern graph and classify
155
+ # ------------------------------------------------------------------
156
+
157
+ def _build_css_graph(
158
+ self,
159
+ store: MigrationStore,
160
+ css_id: str,
161
+ result: MapperResult,
162
+ ) -> dict[str, Any] | None:
163
+ """Build the partition→pattern graph for a CSS, classify partitions.
164
+
165
+ Returns a dict with:
166
+ partitions: list of {partition_id, ordinal, classification, patterns}
167
+ patterns is list of {pattern_id, pattern, action}
168
+
169
+ (from 04-css-decomposition.md Steps 1-2)
170
+ """
171
+ # Get ordered partitions via css_contains_partition with ordinal
172
+ partition_refs = store.get_cross_refs(
173
+ from_id=css_id, relationship="css_contains_partition"
174
+ )
175
+ if not partition_refs:
176
+ return None
177
+
178
+ # Sort by ordinal to preserve CUCM priority order
179
+ partition_refs.sort(key=lambda r: r.get("ordinal") or 0)
180
+
181
+ partitions = []
182
+ for ref in partition_refs:
183
+ partition_id = ref["to_id"]
184
+ ordinal = ref.get("ordinal") or 0
185
+
186
+ # Get patterns in this partition
187
+ pattern_refs = store.get_cross_refs(
188
+ from_id=partition_id, relationship="partition_has_pattern"
189
+ )
190
+
191
+ patterns = []
192
+ for pref in pattern_refs:
193
+ pattern_id = pref["to_id"]
194
+ pat_data = store.get_object(pattern_id)
195
+ if pat_data is None:
196
+ continue
197
+
198
+ state = pat_data.get("pre_migration_state") or {}
199
+ pattern_str = state.get("pattern", "")
200
+ action = state.get("action", "")
201
+ patterns.append({
202
+ "pattern_id": pattern_id,
203
+ "pattern": pattern_str,
204
+ "action": action.upper() if action else "",
205
+ })
206
+
207
+ # Classify partition (from 04-css-decomposition.md Step 2)
208
+ classification = self._classify_partition(patterns)
209
+
210
+ # Handle MIXED partition — split into virtual ROUTING + BLOCKING
211
+ if classification == MIXED:
212
+ route_patterns = [p for p in patterns if p["action"] == "ROUTE"]
213
+ block_patterns = [p for p in patterns if p["action"] == "BLOCK"]
214
+
215
+ # Create virtual ROUTING partition
216
+ partitions.append({
217
+ "partition_id": f"{partition_id}__virtual_routing",
218
+ "ordinal": ordinal,
219
+ "classification": ROUTING,
220
+ "patterns": route_patterns,
221
+ "is_virtual": True,
222
+ "source_partition": partition_id,
223
+ })
224
+
225
+ # Create virtual BLOCKING partition
226
+ partitions.append({
227
+ "partition_id": f"{partition_id}__virtual_blocking",
228
+ "ordinal": ordinal,
229
+ "classification": BLOCKING,
230
+ "patterns": block_patterns,
231
+ "is_virtual": True,
232
+ "source_partition": partition_id,
233
+ })
234
+
235
+ # Generate CSS_ROUTING_MISMATCH decision for MIXED partition
236
+ decision = self._create_decision(
237
+ store=store,
238
+ decision_type=DecisionType.CSS_ROUTING_MISMATCH,
239
+ severity="MEDIUM",
240
+ summary=(
241
+ f"MIXED partition '{partition_id}' in CSS '{css_id}' "
242
+ f"contains both ROUTE and BLOCK patterns — "
243
+ f"split into virtual ROUTING + BLOCKING partitions"
244
+ ),
245
+ context={
246
+ "css_id": css_id,
247
+ "partition_id": partition_id,
248
+ "reason": "mixed_partition",
249
+ "route_count": len(route_patterns),
250
+ "block_count": len(block_patterns),
251
+ },
252
+ options=[
253
+ accept_option(
254
+ "Accept virtual split into ROUTING + BLOCKING partitions"
255
+ ),
256
+ manual_option("Admin manually classifies partition patterns"),
257
+ ],
258
+ affected_objects=[css_id, partition_id],
259
+ )
260
+ store.save_decision(decision_to_store_dict(decision))
261
+ result.decisions.append(decision)
262
+ else:
263
+ partitions.append({
264
+ "partition_id": partition_id,
265
+ "ordinal": ordinal,
266
+ "classification": classification,
267
+ "patterns": patterns,
268
+ "is_virtual": False,
269
+ })
270
+
271
+ return {"partitions": partitions}
272
+
273
+ @staticmethod
274
+ def _classify_partition(patterns: list[dict[str, Any]]) -> str:
275
+ """Classify a partition based on its patterns.
276
+
277
+ (from 04-css-decomposition.md Step 2)
278
+ """
279
+ if not patterns:
280
+ return DIRECTORY
281
+
282
+ has_route = any(p["action"] == "ROUTE" for p in patterns)
283
+ has_block = any(p["action"] == "BLOCK" for p in patterns)
284
+
285
+ # Patterns with no action (DNs only) → directory
286
+ has_any_action = has_route or has_block
287
+
288
+ if not has_any_action:
289
+ return DIRECTORY
290
+ if has_route and has_block:
291
+ return MIXED
292
+ if has_route:
293
+ return ROUTING
294
+ if has_block:
295
+ return BLOCKING
296
+
297
+ return DIRECTORY
298
+
299
+ # ------------------------------------------------------------------
300
+ # Combined CSS: Line CSS (higher priority) + Device CSS (lower priority)
301
+ # ------------------------------------------------------------------
302
+
303
+ def _build_user_effective_css(
304
+ self,
305
+ store: MigrationStore,
306
+ css_graphs: dict[str, dict[str, Any]],
307
+ ) -> dict[str, list[dict[str, Any]]]:
308
+ """Build the effective combined CSS for each user.
309
+
310
+ The effective CSS is Line CSS partitions (higher priority) followed by
311
+ Device CSS partitions (lower priority).
312
+
313
+ (from 03b-transform-mappers.md §7: CSS on devices vs. users)
314
+
315
+ Returns:
316
+ dict mapping user_canonical_id -> combined partition list
317
+ """
318
+ # Gather user→CSS assignments
319
+ user_css_refs = store.get_cross_refs(relationship="user_has_css")
320
+ line_css_refs = store.get_cross_refs(relationship="line_has_css")
321
+ device_css_refs = store.get_cross_refs(relationship="device_has_css")
322
+
323
+ # Build index: user_id → css_ids (direct)
324
+ user_css_map: dict[str, list[str]] = defaultdict(list)
325
+ for ref in user_css_refs:
326
+ user_css_map[ref["from_id"]].append(ref["to_id"])
327
+
328
+ # Build index: line_id → css_id
329
+ line_css_map: dict[str, str] = {}
330
+ for ref in line_css_refs:
331
+ line_css_map[ref["from_id"]] = ref["to_id"]
332
+
333
+ # Build index: device_id → css_id
334
+ device_css_map: dict[str, str] = {}
335
+ for ref in device_css_refs:
336
+ device_css_map[ref["from_id"]] = ref["to_id"]
337
+
338
+ # For users with direct CSS assignment, use that directly
339
+ user_effective: dict[str, list[dict[str, Any]]] = {}
340
+
341
+ for user_id, css_ids in user_css_map.items():
342
+ combined_partitions = []
343
+ for css_id in css_ids:
344
+ graph = css_graphs.get(css_id)
345
+ if graph:
346
+ combined_partitions.extend(graph["partitions"])
347
+ if combined_partitions:
348
+ user_effective[user_id] = combined_partitions
349
+
350
+ # For users without direct CSS, resolve from line CSS (higher
351
+ # priority) then device CSS (lower priority). Trace:
352
+ # user → user_has_device → device → device_has_dn → line
353
+ # Most CUCM deployments assign CSS on the line, not the user.
354
+ users = store.query_by_type("user")
355
+ for user_obj in users:
356
+ uid = user_obj.canonical_id
357
+ if uid in user_effective:
358
+ continue # already has direct CSS
359
+
360
+ combined_partitions: list[dict[str, Any]] = []
361
+ seen_css: set[str] = set()
362
+
363
+ # Get user's devices
364
+ device_ids = store.find_cross_refs(uid, "user_has_device")
365
+
366
+ for dev_id in device_ids:
367
+ # Line CSS (higher priority) — check DNs on this device
368
+ dn_ids = store.find_cross_refs(dev_id, "device_has_dn")
369
+ for dn_id in dn_ids:
370
+ css_id = line_css_map.get(dn_id)
371
+ if css_id and css_id not in seen_css:
372
+ seen_css.add(css_id)
373
+ graph = css_graphs.get(css_id)
374
+ if graph:
375
+ combined_partitions.extend(graph["partitions"])
376
+
377
+ # Device CSS (lower priority)
378
+ css_id = device_css_map.get(dev_id)
379
+ if css_id and css_id not in seen_css:
380
+ seen_css.add(css_id)
381
+ graph = css_graphs.get(css_id)
382
+ if graph:
383
+ combined_partitions.extend(graph["partitions"])
384
+
385
+ if combined_partitions:
386
+ user_effective[uid] = combined_partitions
387
+
388
+ return user_effective
389
+
390
+ # ------------------------------------------------------------------
391
+ # Step 3: Compute routing scope per CSS
392
+ # ------------------------------------------------------------------
393
+
394
+ def _compute_routing_scopes(
395
+ self,
396
+ store: MigrationStore,
397
+ css_graphs: dict[str, dict[str, Any]],
398
+ user_effective_css: dict[str, list[dict[str, Any]]],
399
+ result: MapperResult,
400
+ ) -> None:
401
+ """Compute effective routing scope per CSS, create dial plans.
402
+
403
+ Groups CSSes by identical routing scope. If single group, creates
404
+ org-wide dial plans directly. If multiple groups, uses intersection
405
+ as baseline and flags deltas as CSS_ROUTING_MISMATCH decisions.
406
+
407
+ (from 04-css-decomposition.md Step 3)
408
+ """
409
+ # Compute routing scope per CSS
410
+ css_routing_scopes: dict[str, frozenset[str]] = {}
411
+ # Track pattern→target mapping for dial plan creation
412
+ pattern_targets: dict[str, tuple[str | None, str | None]] = {}
413
+
414
+ for css_id, graph in css_graphs.items():
415
+ route_patterns = set()
416
+ for partition in graph["partitions"]:
417
+ if partition["classification"] == ROUTING:
418
+ for pat in partition["patterns"]:
419
+ if pat["action"] == "ROUTE":
420
+ route_patterns.add(pat["pattern"])
421
+ # Track the target if available from pre_migration_state
422
+ pat_data = store.get_object(pat["pattern_id"])
423
+ if pat_data:
424
+ state = pat_data.get("pre_migration_state") or {}
425
+ target_type = state.get("target_type")
426
+ target_name = state.get("target_name")
427
+ if target_name and target_type:
428
+ if target_type in ("gateway", "trunk"):
429
+ pattern_targets[pat["pattern"]] = (
430
+ f"trunk:{target_name}",
431
+ "TRUNK",
432
+ )
433
+ elif target_type in (
434
+ "route_list",
435
+ "routeList",
436
+ "route_group",
437
+ ):
438
+ pattern_targets[pat["pattern"]] = (
439
+ f"route_group:{target_name}",
440
+ "ROUTE_GROUP",
441
+ )
442
+ css_routing_scopes[css_id] = frozenset(route_patterns)
443
+
444
+ # Group CSSes by identical routing scope
445
+ scope_groups: dict[frozenset[str], list[str]] = defaultdict(list)
446
+ for css_id, scope in css_routing_scopes.items():
447
+ if scope: # Skip empty routing scopes
448
+ scope_groups[scope].append(css_id)
449
+
450
+ if not scope_groups:
451
+ logger.info("No routing patterns found in any CSS")
452
+ return
453
+
454
+ if len(scope_groups) == 1:
455
+ # Happy path: all CSSes have the same routing scope
456
+ # Create org-wide dial plans
457
+ baseline_patterns = next(iter(scope_groups))
458
+ self._create_dial_plans(
459
+ store, baseline_patterns, pattern_targets, result
460
+ )
461
+ else:
462
+ # Multiple routing scopes — use intersection as baseline
463
+ all_scopes = list(scope_groups.keys())
464
+ intersection = all_scopes[0]
465
+ for scope in all_scopes[1:]:
466
+ intersection = intersection & scope
467
+
468
+ # Create dial plans for intersection baseline
469
+ if intersection:
470
+ self._create_dial_plans(
471
+ store, intersection, pattern_targets, result
472
+ )
473
+
474
+ # Generate CSS_ROUTING_MISMATCH decisions for delta patterns
475
+ for scope, css_ids in scope_groups.items():
476
+ delta = scope - intersection
477
+ if delta:
478
+ decision = self._create_decision(
479
+ store=store,
480
+ decision_type=DecisionType.CSS_ROUTING_MISMATCH,
481
+ severity="HIGH",
482
+ summary=(
483
+ f"{len(delta)} route pattern(s) in CSSes "
484
+ f"{css_ids} are NOT in the org-wide intersection "
485
+ f"baseline — these users have broader routing than "
486
+ f"the common baseline"
487
+ ),
488
+ context={
489
+ "css_ids": css_ids,
490
+ "reason": "routing_scope_mismatch",
491
+ "delta_patterns": sorted(delta),
492
+ "baseline_pattern_count": len(intersection),
493
+ },
494
+ options=[
495
+ accept_option(
496
+ "Include delta patterns in org-wide dial plan "
497
+ "(all users gain access; rely on permissions to "
498
+ "restrict)"
499
+ ),
500
+ accept_option(
501
+ "Exclude delta patterns (affected users lose "
502
+ "these routes)"
503
+ ),
504
+ manual_option("Admin reviews and decides per pattern"),
505
+ ],
506
+ affected_objects=css_ids,
507
+ )
508
+ store.save_decision(decision_to_store_dict(decision))
509
+ result.decisions.append(decision)
510
+
511
+ def _create_dial_plans(
512
+ self,
513
+ store: MigrationStore,
514
+ patterns: frozenset[str],
515
+ pattern_targets: dict[str, tuple[str | None, str | None]],
516
+ result: MapperResult,
517
+ ) -> None:
518
+ """Create CanonicalDialPlan objects from a set of route patterns.
519
+
520
+ Groups patterns by their routing target (trunk/route group) and creates
521
+ one dial plan per target. Patterns without a known target are grouped
522
+ into a single 'unresolved' dial plan.
523
+
524
+ (from 03b-transform-mappers.md §7: dial plan field mapping)
525
+ """
526
+ # Group patterns by target
527
+ target_patterns: dict[tuple[str | None, str | None], list[str]] = defaultdict(
528
+ list
529
+ )
530
+ for pattern in patterns:
531
+ target = pattern_targets.get(pattern, (None, None))
532
+ target_patterns[target].append(pattern)
533
+
534
+ for (route_id, route_type), pat_list in target_patterns.items():
535
+ # Convert CUCM patterns to Webex format
536
+ webex_patterns = []
537
+ for pat in pat_list:
538
+ try:
539
+ webex_pat = cucm_to_webex_pattern(
540
+ pat, self.country_code, self.outside_dial_digit
541
+ )
542
+ webex_patterns.append(webex_pat)
543
+ except (ValueError, Exception) as e:
544
+ logger.warning(
545
+ "Failed to convert pattern '%s' to Webex format: %s",
546
+ pat,
547
+ e,
548
+ )
549
+
550
+ if not webex_patterns:
551
+ continue
552
+
553
+ # Generate dial plan name
554
+ if route_id:
555
+ target_name = route_id.split(":", 1)[-1] if ":" in route_id else route_id
556
+ dp_name = f"CSS-Migration-DialPlan-{target_name}"
557
+ else:
558
+ dp_name = "CSS-Migration-DialPlan-Unresolved"
559
+
560
+ dp_hash = hash_id(dp_name + "|" + ",".join(sorted(webex_patterns)))
561
+
562
+ dial_plan = CanonicalDialPlan(
563
+ canonical_id=f"dial_plan:css_{dp_hash}",
564
+ provenance=Provenance(
565
+ source_system="cucm",
566
+ source_id=f"css_routing_scope",
567
+ source_name=dp_name,
568
+ extracted_at=datetime.now(timezone.utc),
569
+ ),
570
+ status=MigrationStatus.ANALYZED,
571
+ name=dp_name,
572
+ dial_patterns=sorted(set(webex_patterns)),
573
+ route_id=route_id,
574
+ route_type=route_type,
575
+ )
576
+
577
+ store.upsert_object(dial_plan)
578
+ result.objects_created += 1
579
+
580
+ # ------------------------------------------------------------------
581
+ # Step 4: Compute restriction profiles
582
+ # ------------------------------------------------------------------
583
+
584
+ def _compute_restriction_profiles(
585
+ self,
586
+ store: MigrationStore,
587
+ css_graphs: dict[str, dict[str, Any]],
588
+ user_effective_css: dict[str, list[dict[str, Any]]],
589
+ result: MapperResult,
590
+ ) -> None:
591
+ """Compute restriction profiles and create CallingPermission objects.
592
+
593
+ For each CSS, classify block patterns into Webex categories.
594
+ Group users by identical restriction profile (frozenset of blocked categories).
595
+ Create one CanonicalCallingPermission per unique profile.
596
+
597
+ (from 04-css-decomposition.md Step 4)
598
+ """
599
+ # Compute restriction profile per CSS
600
+ css_restriction_profiles: dict[str, frozenset[str]] = {}
601
+ css_unclassified: dict[str, list[str]] = {}
602
+
603
+ for css_id, graph in css_graphs.items():
604
+ blocked_categories: set[str] = set()
605
+ unclassified_patterns: list[str] = []
606
+
607
+ for partition in graph["partitions"]:
608
+ if partition["classification"] == BLOCKING:
609
+ for pat in partition["patterns"]:
610
+ if pat["action"] == "BLOCK":
611
+ category = classify_block_pattern(
612
+ pat["pattern"], self.category_rules
613
+ )
614
+ if category:
615
+ blocked_categories.add(category)
616
+ else:
617
+ unclassified_patterns.append(pat["pattern"])
618
+
619
+ css_restriction_profiles[css_id] = frozenset(blocked_categories)
620
+ if unclassified_patterns:
621
+ css_unclassified[css_id] = unclassified_patterns
622
+
623
+ # Generate CALLING_PERMISSION_MISMATCH decisions for unclassifiable patterns
624
+ for css_id, patterns in css_unclassified.items():
625
+ decision = self._create_decision(
626
+ store=store,
627
+ decision_type=DecisionType.CALLING_PERMISSION_MISMATCH,
628
+ severity="MEDIUM",
629
+ summary=(
630
+ f"CSS '{css_id}' has {len(patterns)} block pattern(s) "
631
+ f"that cannot be classified into Webex permission categories: "
632
+ f"{patterns}"
633
+ ),
634
+ context={
635
+ "css_id": css_id,
636
+ "reason": "unclassifiable_block_patterns",
637
+ "unclassified_patterns": patterns,
638
+ },
639
+ options=[
640
+ accept_option(
641
+ "Accept that these blocking restrictions are lost in migration"
642
+ ),
643
+ manual_option(
644
+ "Admin maps patterns to Webex categories or implements "
645
+ "custom blocking dial plan routes"
646
+ ),
647
+ skip_option(
648
+ "Skip restriction — affected users get unrestricted access "
649
+ "for these call types"
650
+ ),
651
+ ],
652
+ affected_objects=[css_id],
653
+ )
654
+ store.save_decision(decision_to_store_dict(decision))
655
+ result.decisions.append(decision)
656
+
657
+ # Group users by identical restriction profile.
658
+ # Use user_effective_css (includes line/device CSS fallback users),
659
+ # not just user_has_css cross-refs, so that users inheriting CSS
660
+ # via line or device also get calling permissions assigned.
661
+ profile_users: dict[frozenset[str], list[str]] = defaultdict(list)
662
+ for user_id, partitions in user_effective_css.items():
663
+ # Compute this user's restriction profile from their effective
664
+ # blocking partitions (same logic as per-CSS, but user-specific)
665
+ user_blocked: set[str] = set()
666
+ for pt_info in partitions:
667
+ if pt_info.get("classification") != "BLOCKING":
668
+ continue
669
+ for pat in pt_info.get("patterns", []):
670
+ cat = classify_block_pattern(
671
+ pat["pattern"], self.category_rules
672
+ )
673
+ if cat:
674
+ user_blocked.add(cat)
675
+ profile = frozenset(user_blocked)
676
+ profile_users[profile].append(user_id)
677
+
678
+ # Also add users from CSSes that have no users mapped via cross-refs
679
+ # (the profile still needs to be created)
680
+
681
+ # Create one CanonicalCallingPermission per unique restriction profile
682
+ for profile_idx, (blocked_categories, users) in enumerate(
683
+ profile_users.items()
684
+ ):
685
+ # Deduplicate users
686
+ unique_users = sorted(set(users))
687
+
688
+ # Skip orphaned profiles — no users reference this CSS
689
+ if not unique_users:
690
+ logger.debug(
691
+ "Skipping orphaned calling permission profile "
692
+ "(categories: %s) — 0 users assigned",
693
+ ",".join(sorted(blocked_categories)) if blocked_categories else "allow_all",
694
+ )
695
+ continue
696
+
697
+ # Build the callingPermissions array
698
+ # All Webex call types get an entry; blocked categories get BLOCK action
699
+ permissions = []
700
+ for call_type in WEBEX_CALL_TYPES:
701
+ # Map our category names to Webex call type names
702
+ is_blocked = self._category_matches_call_type(
703
+ blocked_categories, call_type
704
+ )
705
+ permissions.append(
706
+ CallingPermissionEntry(
707
+ call_type=call_type,
708
+ action="BLOCK" if is_blocked else "ALLOW",
709
+ transfer_enabled=not is_blocked,
710
+ )
711
+ )
712
+
713
+ profile_hash = hash_id(
714
+ ",".join(sorted(blocked_categories)) if blocked_categories else "allow_all"
715
+ )
716
+
717
+ permission = CanonicalCallingPermission(
718
+ canonical_id=f"calling_permission:{profile_hash}",
719
+ provenance=Provenance(
720
+ source_system="cucm",
721
+ source_id=f"css_restriction_profile_{profile_idx}",
722
+ source_name=(
723
+ f"Restriction-{','.join(sorted(blocked_categories))}"
724
+ if blocked_categories
725
+ else "Allow-All"
726
+ ),
727
+ extracted_at=datetime.now(timezone.utc),
728
+ ),
729
+ status=MigrationStatus.ANALYZED,
730
+ calling_permissions=permissions,
731
+ assigned_users=unique_users,
732
+ use_custom_enabled=bool(blocked_categories),
733
+ use_custom_permissions=bool(blocked_categories),
734
+ )
735
+
736
+ store.upsert_object(permission)
737
+ result.objects_created += 1
738
+
739
+ @staticmethod
740
+ def _category_matches_call_type(
741
+ blocked_categories: frozenset[str],
742
+ call_type: str,
743
+ ) -> bool:
744
+ """Check if a Webex call type should be blocked based on blocked categories.
745
+
746
+ Maps the configurable category names (from category_rules) to Webex
747
+ OutgoingPermissionCallType enum values.
748
+
749
+ (from 03b-transform-mappers.md §7: callingPermissions field mapping)
750
+ """
751
+ # Mapping from our category names to Webex call type names
752
+ # This is configurable via category_rules — the webex_category values
753
+ # in the rules should match these keys.
754
+ category_to_call_type: dict[str, list[str]] = {
755
+ "international": ["INTERNATIONAL"],
756
+ "premium": ["PREMIUM_SERVICES_I", "PREMIUM_SERVICES_II"],
757
+ "operator": ["OPERATOR_ASSISTED"],
758
+ "directory_assistance": ["CHARGEABLE_DIRECTORY_ASSISTED"],
759
+ "toll_free": ["TOLL_FREE"],
760
+ "national": ["NATIONAL"],
761
+ "special_services": ["SPECIAL_SERVICES_I", "SPECIAL_SERVICES_II"],
762
+ "internal": ["INTERNAL_CALL"],
763
+ }
764
+
765
+ for category in blocked_categories:
766
+ mapped_types = category_to_call_type.get(category, [])
767
+ if call_type in mapped_types:
768
+ return True
769
+
770
+ return False
771
+
772
+ # ------------------------------------------------------------------
773
+ # Step 5: Detect ordering conflicts
774
+ # ------------------------------------------------------------------
775
+
776
+ def _detect_ordering_conflicts(
777
+ self,
778
+ store: MigrationStore,
779
+ css_graphs: dict[str, dict[str, Any]],
780
+ result: MapperResult,
781
+ ) -> None:
782
+ """Detect cases where CUCM partition ordering produces different behavior
783
+ than Webex's independent routing+permissions model.
784
+
785
+ (from 04-css-decomposition.md Step 5)
786
+ """
787
+ for css_id, graph in css_graphs.items():
788
+ # Collect all patterns with their action and ordinal, sorted by ordinal
789
+ all_patterns: list[tuple[str, str, int]] = []
790
+ for partition in graph["partitions"]:
791
+ for pat in partition["patterns"]:
792
+ if pat["action"] in ("ROUTE", "BLOCK"):
793
+ all_patterns.append(
794
+ (pat["pattern"], pat["action"], partition["ordinal"])
795
+ )
796
+
797
+ # Sort by ordinal (priority order)
798
+ all_patterns.sort(key=lambda x: x[2])
799
+
800
+ # Check each pair where a higher-priority pattern shadows a lower one
801
+ for i, (pat_a, action_a, prio_a) in enumerate(all_patterns):
802
+ for pat_b, action_b, prio_b in all_patterns[i + 1 :]:
803
+ if action_a == action_b:
804
+ continue # Same action, no conflict
805
+ if not cucm_patterns_overlap(pat_a, pat_b):
806
+ continue # No overlap, no shadowing
807
+
808
+ # Determine risk
809
+ if action_a == "ROUTE" and action_b == "BLOCK":
810
+ risk = "Webex MORE restrictive than CUCM"
811
+ cucm_behavior = (
812
+ f"ALLOWED (route '{pat_a}' at priority {prio_a} "
813
+ f"shadows block '{pat_b}' at priority {prio_b})"
814
+ )
815
+ webex_behavior = (
816
+ "BLOCKED (permissions enforced independently of routing)"
817
+ )
818
+ severity = "MEDIUM"
819
+ elif action_a == "BLOCK" and action_b == "ROUTE":
820
+ risk = "Webex LESS restrictive than CUCM"
821
+ cucm_behavior = (
822
+ f"BLOCKED (block '{pat_a}' at priority {prio_a} "
823
+ f"shadows route '{pat_b}' at priority {prio_b})"
824
+ )
825
+ webex_behavior = (
826
+ "ALLOWED (dial plan routes, no matching permission block)"
827
+ )
828
+ severity = "HIGH" # LESS restrictive is riskier
829
+ else:
830
+ continue
831
+
832
+ decision = self._create_decision(
833
+ store=store,
834
+ decision_type=DecisionType.CSS_ROUTING_MISMATCH,
835
+ severity=severity,
836
+ summary=(
837
+ f"Ordering conflict in CSS '{css_id}': "
838
+ f"{risk}. CUCM: {cucm_behavior}. "
839
+ f"Webex: {webex_behavior}."
840
+ ),
841
+ context={
842
+ "css_id": css_id,
843
+ "reason": "ordering_conflict",
844
+ "shadowing_pattern": pat_a,
845
+ "shadowed_pattern": pat_b,
846
+ "shadowing_action": action_a,
847
+ "shadowed_action": action_b,
848
+ "shadowing_priority": prio_a,
849
+ "shadowed_priority": prio_b,
850
+ "risk": risk,
851
+ "cucm_behavior": cucm_behavior,
852
+ "webex_behavior": webex_behavior,
853
+ },
854
+ options=[
855
+ accept_option(
856
+ f"Accept Webex behavior ({risk})"
857
+ ),
858
+ manual_option(
859
+ "Admin adjusts permissions to compensate "
860
+ "for ordering difference"
861
+ ),
862
+ ],
863
+ affected_objects=[css_id],
864
+ )
865
+ store.save_decision(decision_to_store_dict(decision))
866
+ result.decisions.append(decision)
867
+
868
+