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,300 @@
1
+ """Missing data analyzer for CUCM-to-Webex migration.
2
+
3
+ Sweeps the inventory for objects with null/empty required fields
4
+ and produces MISSING_DATA decisions so users can supply the data
5
+ or choose to skip the object.
6
+
7
+ MISSING_DATA is a mapper-owned decision type — multiple mappers
8
+ can produce these decisions. This analyzer checks for existing
9
+ mapper decisions before creating new ones.
10
+
11
+ (from 03-conflict-detection-engine.md, linter pattern)
12
+ (from phase-06-analyzers.md, Decision Ownership Table — MISSING_DATA is mapper-owned)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import DecisionOption, DecisionType
20
+ from wxcli.migration.store import MigrationStore
21
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Required-field definitions per object type
26
+ # ---------------------------------------------------------------------------
27
+
28
+ # Each entry: (field_name, check_fn, severity, description)
29
+ # check_fn receives the field value and returns True if the value is MISSING.
30
+
31
+ def _is_empty_list(val: Any) -> bool:
32
+ return val is None or (isinstance(val, list) and len(val) == 0)
33
+
34
+
35
+ def _is_none(val: Any) -> bool:
36
+ return val is None
37
+
38
+
39
+ def _is_none_or_blank(val: Any) -> bool:
40
+ return val is None or (isinstance(val, str) and val.strip() == "")
41
+
42
+
43
+ def _get_nested(obj: dict, dotted_key: str) -> Any:
44
+ """Retrieve a value from a dict using dot-notation (e.g., 'address.country')."""
45
+ parts = dotted_key.split(".")
46
+ val = obj
47
+ for p in parts:
48
+ if isinstance(val, dict):
49
+ val = val.get(p)
50
+ else:
51
+ return None
52
+ return val
53
+
54
+
55
+ _REQUIRED_FIELDS: dict[str, list[tuple[str, Any, str, str]]] = {
56
+ "user": [
57
+ ("emails", _is_empty_list, "HIGH", "Email address required for Webex identity"),
58
+ ("first_name", _is_none_or_blank, "MEDIUM", "First or last name required"),
59
+ ("last_name", _is_none_or_blank, "MEDIUM", "First or last name required"),
60
+ # Fix 5: location_id required for Webex Calling provisioning
61
+ ("location_id", _is_none_or_blank, "HIGH", "Location assignment required for Webex Calling"),
62
+ ],
63
+ "device": [
64
+ ("mac", _is_none_or_blank, "MEDIUM", "MAC address required for device migration"),
65
+ ("owner_canonical_id", _is_none_or_blank, "MEDIUM", "Device owner required for assignment"),
66
+ ],
67
+ "trunk": [
68
+ ("address", _is_none_or_blank, "MEDIUM", "FQDN/SRV address required for trunk registration"),
69
+ # password NOT checked — Webex Calling trunks use certificate-based TLS,
70
+ # not SIP password auth. CUCM never exposes passwords via AXL anyway.
71
+ ],
72
+ "location": [
73
+ ("name", _is_none_or_blank, "MEDIUM", "Location name required"),
74
+ # Fix 4: Address fields — E911 compliance
75
+ ("address.country", _is_none_or_blank, "HIGH", "Country required for E911 compliance"),
76
+ ("address.address1", _is_none_or_blank, "HIGH", "Street address required for E911 compliance"),
77
+ ("address.city", _is_none_or_blank, "HIGH", "City required for E911 compliance"),
78
+ ("address.state", _is_none_or_blank, "HIGH", "State/province required for E911 compliance"),
79
+ ("address.postal_code", _is_none_or_blank, "MEDIUM", "Postal code required for full address"),
80
+ ],
81
+ # Fix 6: Translation pattern required fields
82
+ "translation_pattern": [
83
+ ("matching_pattern", _is_none_or_blank, "HIGH", "Pattern required for translation"),
84
+ ("name", _is_none_or_blank, "MEDIUM", "Name required for identification"),
85
+ ],
86
+ # Fix 11: Route group required fields
87
+ "route_group": [
88
+ ("local_gateways", _is_empty_list, "HIGH", "At least one gateway required"),
89
+ ],
90
+ # Lines handled separately in _check_lines() — extension is only
91
+ # required for EXTENSION-classified lines (special logic).
92
+ }
93
+
94
+
95
+ class MissingDataAnalyzer(Analyzer):
96
+ """Finds objects where required fields are null or empty.
97
+
98
+ For users: checks emails (HIGH — blocks identity provisioning), first_name/last_name.
99
+ For users with name fields: only flags if BOTH first_name AND last_name are missing
100
+ (having either one is acceptable).
101
+
102
+ For devices: checks mac and owner_canonical_id.
103
+ For trunks: checks address and password.
104
+ For locations: checks name.
105
+ For lines: checks extension (only when classification == EXTENSION).
106
+
107
+ (from 03-conflict-detection-engine.md, MissingDataAnalyzer)
108
+ """
109
+
110
+ name = "missing_data"
111
+ decision_types = [DecisionType.MISSING_DATA]
112
+
113
+ def analyze(self, store: MigrationStore) -> list[Decision]:
114
+ """Sweep all object types for missing required fields."""
115
+ # Collect existing mapper-produced MISSING_DATA decisions so we skip
116
+ # objects that already have one.
117
+ existing = self._get_existing_decisions_for_type(store, DecisionType.MISSING_DATA)
118
+
119
+ decisions: list[Decision] = []
120
+
121
+ for object_type, field_defs in _REQUIRED_FIELDS.items():
122
+ objects = store.get_objects(object_type)
123
+ for obj in objects:
124
+ canonical_id = obj.get("canonical_id", "")
125
+
126
+ # Skip if mapper already produced a MISSING_DATA decision
127
+ if canonical_id in existing:
128
+ continue
129
+
130
+ # Webex App devices (Jabber, CSF, Dual Mode, IP Communicator)
131
+ # and infrastructure devices (CTI, CER, gateways) don't need
132
+ # a MAC address or owner — they aren't migrated as Webex devices.
133
+ if object_type == "device" and obj.get("compatibility_tier") in ("webex_app", "infrastructure", "dect"):
134
+ continue # Skip all missing-data checks for non-migratable devices
135
+ else:
136
+ active_fields = field_defs
137
+
138
+ missing = self._check_object(object_type, obj, active_fields)
139
+ if not missing:
140
+ continue
141
+
142
+ # Determine severity — take the highest among missing fields
143
+ severity = self._highest_severity(missing)
144
+ missing_names = sorted([m[0] for m in missing])
145
+
146
+ summary = (
147
+ f"{object_type} '{canonical_id}' missing required fields: "
148
+ f"{', '.join(missing_names)}"
149
+ )
150
+
151
+ context = {
152
+ "object_type": object_type,
153
+ "canonical_id": canonical_id,
154
+ "missing_fields": missing_names,
155
+ }
156
+
157
+ options = [
158
+ DecisionOption(
159
+ id="provide_data",
160
+ label="Provide missing data",
161
+ impact=f"User will supply {len(missing_names)} missing field(s)",
162
+ ),
163
+ DecisionOption(
164
+ id="skip",
165
+ label="Skip this object",
166
+ impact=f"1 {object_type} excluded from migration",
167
+ ),
168
+ DecisionOption(
169
+ id="manual",
170
+ label="Handle manually post-migration",
171
+ impact=f"Object migrated without {', '.join(missing_names)}; fix manually",
172
+ ),
173
+ ]
174
+
175
+ decision = self._create_decision(
176
+ store=store,
177
+ decision_type=DecisionType.MISSING_DATA,
178
+ severity=severity,
179
+ summary=summary,
180
+ context=context,
181
+ options=options,
182
+ affected_objects=[canonical_id],
183
+ )
184
+ decisions.append(decision)
185
+
186
+ # Handle lines separately (extension-only check for EXTENSION lines)
187
+ decisions.extend(self._check_lines(store, existing))
188
+
189
+ return decisions
190
+
191
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
192
+ """Deterministic fingerprint from canonical_id + sorted missing fields."""
193
+ return self._hash_fingerprint({
194
+ "type": decision_type.value,
195
+ "canonical_id": context.get("canonical_id", ""),
196
+ "missing_fields": sorted(context.get("missing_fields", [])),
197
+ })
198
+
199
+ # ------------------------------------------------------------------
200
+ # Internal helpers
201
+ # ------------------------------------------------------------------
202
+
203
+ @staticmethod
204
+ def _check_object(
205
+ object_type: str,
206
+ obj: dict[str, Any],
207
+ field_defs: list[tuple[str, Any, str, str]],
208
+ ) -> list[tuple[str, str, str]]:
209
+ """Return list of (field_name, severity, description) for missing fields.
210
+
211
+ Special case for user: first_name/last_name only flagged when BOTH missing.
212
+ Supports dot-notation for nested fields (e.g., 'address.country').
213
+ """
214
+ missing: list[tuple[str, str, str]] = []
215
+
216
+ if object_type == "user":
217
+ # Email and location_id are always required
218
+ for field_name, check_fn, severity, desc in field_defs:
219
+ if field_name in ("emails", "location_id"):
220
+ val = _get_nested(obj, field_name) if "." in field_name else obj.get(field_name)
221
+ if check_fn(val):
222
+ missing.append((field_name, severity, desc))
223
+ # Name: flag only if both first_name AND last_name are missing
224
+ first = obj.get("first_name")
225
+ last = obj.get("last_name")
226
+ if _is_none_or_blank(first) and _is_none_or_blank(last):
227
+ missing.append(("first_name/last_name", "MEDIUM", "First or last name required"))
228
+ else:
229
+ for field_name, check_fn, severity, desc in field_defs:
230
+ # Support dot-notation for nested fields (Fix 4)
231
+ val = _get_nested(obj, field_name) if "." in field_name else obj.get(field_name)
232
+ if check_fn(val):
233
+ missing.append((field_name, severity, desc))
234
+
235
+ return missing
236
+
237
+ def _check_lines(
238
+ self,
239
+ store: MigrationStore,
240
+ existing: dict[str, dict[str, Any]],
241
+ ) -> list[Decision]:
242
+ """Check lines: extension is required only for EXTENSION-classified lines."""
243
+ decisions: list[Decision] = []
244
+ lines = store.get_objects("line")
245
+
246
+ for line in lines:
247
+ canonical_id = line.get("canonical_id", "")
248
+ if canonical_id in existing:
249
+ continue
250
+
251
+ classification = line.get("classification")
252
+ extension = line.get("extension")
253
+
254
+ # Only flag missing extension for EXTENSION-classified lines
255
+ if classification == "EXTENSION" and _is_none_or_blank(extension):
256
+ context = {
257
+ "object_type": "line",
258
+ "canonical_id": canonical_id,
259
+ "missing_fields": ["extension"],
260
+ }
261
+ decision = self._create_decision(
262
+ store=store,
263
+ decision_type=DecisionType.MISSING_DATA,
264
+ severity="MEDIUM",
265
+ summary=(
266
+ f"line '{canonical_id}' classified as EXTENSION but extension is empty"
267
+ ),
268
+ context=context,
269
+ options=[
270
+ DecisionOption(
271
+ id="provide_data",
272
+ label="Provide extension number",
273
+ impact="User will supply the extension value",
274
+ ),
275
+ DecisionOption(
276
+ id="skip",
277
+ label="Skip this line",
278
+ impact="1 line excluded from migration",
279
+ ),
280
+ DecisionOption(
281
+ id="manual",
282
+ label="Handle manually post-migration",
283
+ impact="Line migrated without extension; fix manually",
284
+ ),
285
+ ],
286
+ affected_objects=[canonical_id],
287
+ )
288
+ decisions.append(decision)
289
+
290
+ return decisions
291
+
292
+ @staticmethod
293
+ def _highest_severity(missing: list[tuple[str, str, str]]) -> str:
294
+ """Return the highest severity among the missing fields."""
295
+ order = {"HIGH": 3, "MEDIUM": 2, "LOW": 1}
296
+ best = "LOW"
297
+ for _, sev, _ in missing:
298
+ if order.get(sev, 0) > order.get(best, 0):
299
+ best = sev
300
+ return best
@@ -0,0 +1,394 @@
1
+ """Selective call handling analyzer for CUCM-to-Webex migration.
2
+
3
+ Detects CUCM CSS/partition patterns that imply per-caller routing differences
4
+ and produces FEATURE_APPROXIMATION advisory decisions recommending Webex
5
+ selective call handling features (Selective Forward / Accept / Reject /
6
+ Priority Alert).
7
+
8
+ Three heuristics:
9
+ 1. Multi-partition DN — same DN in 2+ partitions reachable via different
10
+ user CSSes (internal vs external routing split).
11
+ 2. Low-membership partition — partition with few DNs that appears in only
12
+ a subset of CSSes (VIP/priority bypass pattern).
13
+ 3. Naming convention — partition name contains VIP/executive/priority
14
+ keywords (weak signal, only LOW severity unless paired with structural
15
+ signal).
16
+
17
+ This analyzer reuses DecisionType.FEATURE_APPROXIMATION with a distinguishing
18
+ context key `selective_call_handling_pattern`. The recommendation rule and
19
+ report renderer key off this context key.
20
+
21
+ Spec: docs/superpowers/specs/2026-04-10-selective-call-forwarding.md
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections import defaultdict
27
+ from typing import Any
28
+
29
+ from wxcli.migration.models import DecisionOption, DecisionType
30
+ from wxcli.migration.store import MigrationStore
31
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
32
+
33
+
34
+ _VIP_KEYWORDS = (
35
+ "vip",
36
+ "executive",
37
+ "exec",
38
+ "priority",
39
+ "direct",
40
+ "bypass",
41
+ "afterhours",
42
+ "after_hours",
43
+ "after-hours",
44
+ "emergency",
45
+ )
46
+
47
+
48
+ class SelectiveCallHandlingAnalyzer(Analyzer):
49
+ """Detects CSS/partition patterns suggesting selective call handling.
50
+
51
+ See module docstring for the three heuristics. Produces
52
+ FEATURE_APPROXIMATION decisions with context key
53
+ `selective_call_handling_pattern` set to one of:
54
+ - "multi_partition_dn"
55
+ - "low_membership_partition"
56
+ - "naming_convention"
57
+ """
58
+
59
+ name = "selective_call_handling"
60
+ decision_types = [DecisionType.FEATURE_APPROXIMATION]
61
+ # Run after css_routing so CSS decomposition decisions are merged first.
62
+ depends_on = ["css_routing"]
63
+
64
+ def analyze(self, store: MigrationStore) -> list[Decision]:
65
+ decisions: list[Decision] = []
66
+ decisions.extend(self._heuristic_multi_partition_dn(store))
67
+ decisions.extend(self._heuristic_low_membership_partition(store))
68
+ decisions.extend(self._heuristic_naming_convention(store, decisions))
69
+ return decisions
70
+
71
+ def fingerprint(
72
+ self, decision_type: DecisionType, context: dict[str, Any]
73
+ ) -> str:
74
+ """Deterministic fingerprint from pattern type + natural keys."""
75
+ return self._hash_fingerprint({
76
+ "type": decision_type.value,
77
+ "pattern": context.get("selective_call_handling_pattern", ""),
78
+ "primary_key": context.get("primary_key", ""),
79
+ "partitions": sorted(context.get("partitions", [])),
80
+ "user": context.get("user_canonical_id", ""),
81
+ })
82
+
83
+ # ------------------------------------------------------------------
84
+ # Heuristics — implemented in later tasks
85
+ # ------------------------------------------------------------------
86
+
87
+ def _heuristic_multi_partition_dn(
88
+ self, store: MigrationStore
89
+ ) -> list[Decision]:
90
+ """Find DNs in 2+ partitions reachable via different user CSSes."""
91
+ # Build {dn_number: {partition_name: dn_canonical_id}} from cross_refs.
92
+ # The dn_canonical_id form is "dn:{number}:{partition}".
93
+ rows = store.conn.execute(
94
+ "SELECT from_id, to_id FROM cross_refs WHERE relationship = 'dn_in_partition'"
95
+ ).fetchall()
96
+ dn_to_partitions: dict[str, dict[str, str]] = defaultdict(dict)
97
+ for row in rows:
98
+ dn_canonical_id = row["from_id"]
99
+ partition_canonical_id = row["to_id"]
100
+ # Extract the dn number from "dn:{number}:{partition}"
101
+ parts = dn_canonical_id.split(":", 2)
102
+ if len(parts) < 3:
103
+ continue
104
+ dn_number = parts[1]
105
+ partition_name = (
106
+ partition_canonical_id.split(":", 1)[1]
107
+ if ":" in partition_canonical_id
108
+ else partition_canonical_id
109
+ )
110
+ dn_to_partitions[dn_number][partition_name] = dn_canonical_id
111
+
112
+ # Map user_id → set of CSS canonical_ids and CSS → set of partition_ids
113
+ user_csses = self._build_user_css_index(store)
114
+ css_partitions = self._build_css_partition_index(store)
115
+ # Map dn canonical_id → owner user canonical_id (from user_has_primary_dn)
116
+ owner_rows = store.conn.execute(
117
+ "SELECT from_id, to_id FROM cross_refs WHERE relationship = 'user_has_primary_dn'"
118
+ ).fetchall()
119
+ dn_owner: dict[str, str] = {row["to_id"]: row["from_id"] for row in owner_rows}
120
+
121
+ decisions: list[Decision] = []
122
+ for dn_number, partition_map in sorted(dn_to_partitions.items()):
123
+ if len(partition_map) < 2:
124
+ continue
125
+
126
+ # Multi-site filter (mitigation 8a):
127
+ # Get owners across all partition variants for this DN number.
128
+ owner_ids: set[str] = set()
129
+ for dn_cid in partition_map.values():
130
+ owner = dn_owner.get(dn_cid)
131
+ if owner:
132
+ owner_ids.add(owner)
133
+ owner_locations = self._owner_locations(store, owner_ids)
134
+ if len(owner_locations) > 1:
135
+ # Multi-site routing pattern, not selective call handling.
136
+ continue
137
+
138
+ # Determine severity: MEDIUM if owning user's CSSes have
139
+ # different scopes (different partition sets).
140
+ primary_owner = next(iter(owner_ids), "")
141
+ scopes = self._user_css_scopes(primary_owner, user_csses, css_partitions)
142
+ severity = "MEDIUM" if self._scopes_differ(scopes) else "LOW"
143
+
144
+ partitions_sorted = sorted(partition_map.keys())
145
+ context = {
146
+ "selective_call_handling_pattern": "multi_partition_dn",
147
+ "primary_key": dn_number,
148
+ "dn_number": dn_number,
149
+ "partitions": partitions_sorted,
150
+ "user_canonical_id": primary_owner,
151
+ "recommended_webex_feature": "Selective Forward",
152
+ "confidence": "HIGH" if severity == "MEDIUM" else "LOW",
153
+ }
154
+ options = self._build_options("Selective Forward")
155
+ summary = (
156
+ f"DN {dn_number} appears in {len(partitions_sorted)} partitions "
157
+ f"({', '.join(partitions_sorted)}); CUCM caller-specific routing "
158
+ f"pattern detected"
159
+ )
160
+ decisions.append(
161
+ self._create_decision(
162
+ store=store,
163
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
164
+ severity=severity,
165
+ summary=summary,
166
+ context=context,
167
+ options=options,
168
+ affected_objects=[primary_owner] if primary_owner else [],
169
+ )
170
+ )
171
+ return decisions
172
+
173
+ # ------------------------------------------------------------------
174
+ # Shared helpers
175
+ # ------------------------------------------------------------------
176
+
177
+ def _build_user_css_index(
178
+ self, store: MigrationStore
179
+ ) -> dict[str, set[str]]:
180
+ rows = store.conn.execute(
181
+ "SELECT from_id, to_id FROM cross_refs WHERE relationship = 'user_has_css'"
182
+ ).fetchall()
183
+ index: dict[str, set[str]] = defaultdict(set)
184
+ for row in rows:
185
+ index[row["from_id"]].add(row["to_id"])
186
+ return index
187
+
188
+ def _build_css_partition_index(
189
+ self, store: MigrationStore
190
+ ) -> dict[str, set[str]]:
191
+ rows = store.conn.execute(
192
+ "SELECT from_id, to_id FROM cross_refs WHERE relationship = 'css_contains_partition'"
193
+ ).fetchall()
194
+ index: dict[str, set[str]] = defaultdict(set)
195
+ for row in rows:
196
+ index[row["from_id"]].add(row["to_id"])
197
+ return index
198
+
199
+ def _owner_locations(
200
+ self, store: MigrationStore, owner_ids: set[str]
201
+ ) -> set[str]:
202
+ locations: set[str] = set()
203
+ for owner_id in owner_ids:
204
+ obj = store.get_object(owner_id)
205
+ if obj is None:
206
+ continue
207
+ loc = obj.get("location_id")
208
+ if loc:
209
+ locations.add(loc)
210
+ return locations
211
+
212
+ def _user_css_scopes(
213
+ self,
214
+ user_id: str,
215
+ user_csses: dict[str, set[str]],
216
+ css_partitions: dict[str, set[str]],
217
+ ) -> list[set[str]]:
218
+ return [
219
+ css_partitions.get(css_id, set())
220
+ for css_id in user_csses.get(user_id, set())
221
+ ]
222
+
223
+ @staticmethod
224
+ def _scopes_differ(scopes: list[set[str]]) -> bool:
225
+ if len(scopes) < 2:
226
+ return False
227
+ # Any pair with non-empty symmetric difference → scopes differ.
228
+ for i in range(len(scopes)):
229
+ for j in range(i + 1, len(scopes)):
230
+ if scopes[i] ^ scopes[j]:
231
+ return True
232
+ return False
233
+
234
+ @staticmethod
235
+ def _build_options(recommended_feature: str) -> list[DecisionOption]:
236
+ return [
237
+ DecisionOption(
238
+ id="accept",
239
+ label=f"Configure {recommended_feature} post-migration",
240
+ impact=(
241
+ f"Operator manually configures Webex {recommended_feature} "
242
+ f"to preserve CUCM caller-specific routing behavior"
243
+ ),
244
+ ),
245
+ DecisionOption(
246
+ id="skip",
247
+ label="Skip — accept loss of caller-specific routing",
248
+ impact=(
249
+ "Per-caller routing differences are not preserved; "
250
+ "callers reach the user via standard Webex routing"
251
+ ),
252
+ ),
253
+ ]
254
+
255
+ def _heuristic_low_membership_partition(
256
+ self, store: MigrationStore
257
+ ) -> list[Decision]:
258
+ """Find partitions with few DNs that appear in only a subset of CSSes."""
259
+ partitions = store.get_objects("partition")
260
+ if not partitions:
261
+ return []
262
+ css_partitions = self._build_css_partition_index(store)
263
+ # total_css_count must reflect all CSSes in the store (not just those
264
+ # that appear as keys in the cross_ref index — a CSS with no partitions
265
+ # assigned still counts toward the "universe" of CSSes).
266
+ total_css_count = len(store.get_objects("css"))
267
+ if total_css_count == 0:
268
+ return []
269
+
270
+ # Build reverse: partition_id → set of CSS canonical_ids that contain it
271
+ partition_in_css: dict[str, set[str]] = defaultdict(set)
272
+ for css_id, pt_ids in css_partitions.items():
273
+ for pt_id in pt_ids:
274
+ partition_in_css[pt_id].add(css_id)
275
+
276
+ decisions: list[Decision] = []
277
+ for pt in partitions:
278
+ pt_id = pt.get("canonical_id", "")
279
+ if not pt_id:
280
+ continue
281
+ dn_refs = store.find_cross_refs(pt_id, "partition_has_pattern")
282
+ dn_count = len(dn_refs)
283
+ if dn_count == 0 or dn_count > 10:
284
+ continue
285
+
286
+ css_count = len(partition_in_css.get(pt_id, set()))
287
+ # Universal partition or not in a strict minority of CSSes → not selective.
288
+ if css_count == 0 or css_count >= total_css_count * 0.5:
289
+ continue
290
+
291
+ partition_name = (
292
+ pt.get("pre_migration_state", {}).get("partition_name")
293
+ or pt_id.split(":", 1)[-1]
294
+ )
295
+
296
+ context = {
297
+ "selective_call_handling_pattern": "low_membership_partition",
298
+ "primary_key": partition_name,
299
+ "partitions": [partition_name],
300
+ "dn_count": dn_count,
301
+ "css_count": css_count,
302
+ "total_css_count": total_css_count,
303
+ "recommended_webex_feature": "Selective Accept",
304
+ "confidence": "HIGH",
305
+ }
306
+ options = self._build_options("Selective Accept")
307
+ summary = (
308
+ f"Partition '{partition_name}' has {dn_count} DN(s) and is "
309
+ f"reachable via only {css_count}/{total_css_count} CSSes "
310
+ f"(VIP/priority bypass pattern)"
311
+ )
312
+ decisions.append(
313
+ self._create_decision(
314
+ store=store,
315
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
316
+ severity="MEDIUM",
317
+ summary=summary,
318
+ context=context,
319
+ options=options,
320
+ affected_objects=[pt_id],
321
+ )
322
+ )
323
+ return decisions
324
+
325
+ def _heuristic_naming_convention(
326
+ self,
327
+ store: MigrationStore,
328
+ existing_decisions: list[Decision],
329
+ ) -> list[Decision]:
330
+ """Flag VIP/executive-named partitions not already covered structurally."""
331
+ partitions = store.get_objects("partition")
332
+ if not partitions:
333
+ return []
334
+
335
+ # Set of partition canonical_ids already covered by a structural heuristic
336
+ covered: set[str] = set()
337
+ for dec in existing_decisions:
338
+ for obj_id in dec.affected_objects:
339
+ if obj_id.startswith("partition:"):
340
+ covered.add(obj_id)
341
+
342
+ decisions: list[Decision] = []
343
+ for pt in partitions:
344
+ pt_id = pt.get("canonical_id", "")
345
+ if pt_id in covered:
346
+ continue
347
+ partition_name = (
348
+ pt.get("pre_migration_state", {}).get("partition_name")
349
+ or pt_id.split(":", 1)[-1]
350
+ )
351
+ if not self._matches_vip_keyword(partition_name):
352
+ continue
353
+
354
+ context = {
355
+ "selective_call_handling_pattern": "naming_convention",
356
+ "primary_key": partition_name,
357
+ "partitions": [partition_name],
358
+ "matched_keyword": self._matched_keyword(partition_name),
359
+ "recommended_webex_feature": "Priority Alert",
360
+ "confidence": "LOW",
361
+ }
362
+ options = self._build_options("Priority Alert")
363
+ summary = (
364
+ f"Partition '{partition_name}' name suggests selective access "
365
+ f"(matched keyword '{context['matched_keyword']}'); CUCM "
366
+ f"caller-specific routing intent inferred from naming only"
367
+ )
368
+ decisions.append(
369
+ self._create_decision(
370
+ store=store,
371
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
372
+ severity="LOW",
373
+ summary=summary,
374
+ context=context,
375
+ options=options,
376
+ affected_objects=[pt_id],
377
+ )
378
+ )
379
+ return decisions
380
+
381
+ @staticmethod
382
+ def _matches_vip_keyword(name: str) -> bool:
383
+ if not name:
384
+ return False
385
+ lowered = name.lower()
386
+ return any(kw in lowered for kw in _VIP_KEYWORDS)
387
+
388
+ @staticmethod
389
+ def _matched_keyword(name: str) -> str:
390
+ lowered = (name or "").lower()
391
+ for kw in _VIP_KEYWORDS:
392
+ if kw in lowered:
393
+ return kw
394
+ return ""