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,170 @@
1
+ """Shared line analyzer — finds DNs shared across multiple device owners.
2
+
3
+ Detects directory numbers that appear on devices belonging to different users.
4
+ These shared-line appearances require a migration decision because Webex Calling
5
+ handles multi-user shared lines differently from CUCM (virtual lines, Webex
6
+ shared lines, or call park + BLF).
7
+
8
+ Decision ownership: ANALYZER-OWNED (no mapper produces SHARED_LINE_COMPLEX).
9
+
10
+ (from 03-conflict-detection-engine.md, shared line detection)
11
+ (from 07-idempotency-resumability.md, fingerprint-based identity)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
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
+ class SharedLineAnalyzer(Analyzer):
25
+ """Finds DNs shared across multiple device owners.
26
+
27
+ CUCM allows a single DN to appear on multiple devices owned by different
28
+ users. Webex Calling supports simple two-device shared lines natively,
29
+ but more complex arrangements (3+ devices, multiple owners) require
30
+ virtual lines, call park + BLF, or other workarounds.
31
+
32
+ Detection: query cross_refs for device_has_dn relationships, group by DN,
33
+ find DNs referenced by 2+ devices, then resolve unique owners.
34
+ """
35
+
36
+ name = "shared_lines"
37
+ decision_types = [DecisionType.SHARED_LINE_COMPLEX]
38
+
39
+ def analyze(self, store: MigrationStore) -> list[Decision]:
40
+ """Sweep all device-to-DN cross-refs, find DNs with multiple device owners."""
41
+ decisions: list[Decision] = []
42
+
43
+ # Step 1: Find DNs referenced by multiple devices via raw SQL
44
+ rows = store.conn.execute("""
45
+ SELECT cr.to_id AS dn_id, COUNT(DISTINCT cr.from_id) AS device_count
46
+ FROM cross_refs cr
47
+ WHERE cr.relationship = 'device_has_dn'
48
+ GROUP BY cr.to_id
49
+ HAVING COUNT(DISTINCT cr.from_id) > 1
50
+ """).fetchall()
51
+
52
+ if not rows:
53
+ return decisions
54
+
55
+ # Step 2: For each shared DN, gather details and resolve owners
56
+ for row in rows:
57
+ dn_id: str = row["dn_id"]
58
+ device_count: int = row["device_count"]
59
+
60
+ # Get DN data
61
+ dn_data = store.get_object(dn_id)
62
+ if dn_data is None:
63
+ continue
64
+
65
+ extension = dn_data.get("extension")
66
+ cucm_pattern = dn_data.get("cucm_pattern")
67
+ route_partition_name = dn_data.get("route_partition_name")
68
+
69
+ # Get all devices that reference this DN
70
+ device_refs = store.conn.execute(
71
+ "SELECT from_id FROM cross_refs WHERE to_id = ? AND relationship = 'device_has_dn'",
72
+ (dn_id,),
73
+ ).fetchall()
74
+ device_ids = [r["from_id"] for r in device_refs]
75
+
76
+ # Resolve unique owners from each device's data
77
+ owner_set: set[str] = set()
78
+ owner_ids: list[str] = []
79
+ for dev_id in device_ids:
80
+ dev_data = store.get_object(dev_id)
81
+ if dev_data is not None:
82
+ owner = dev_data.get("owner_canonical_id")
83
+ if owner and owner not in owner_set:
84
+ owner_set.add(owner)
85
+ owner_ids.append(owner)
86
+
87
+ # Only flag if there are multiple unique owners
88
+ if len(owner_ids) < 2:
89
+ continue
90
+
91
+ unique_owners = len(owner_ids)
92
+ severity = "HIGH" if unique_owners > 2 else "MEDIUM"
93
+
94
+ # Build options
95
+ dn_display = extension or cucm_pattern or dn_id
96
+
97
+ options = [
98
+ DecisionOption(
99
+ id="virtual_line",
100
+ label="Virtual Line",
101
+ impact=f"1 virtual line + {device_count} line assignments",
102
+ ),
103
+ ]
104
+
105
+ if device_count <= 2:
106
+ options.append(
107
+ DecisionOption(
108
+ id="shared_line",
109
+ label="Webex Shared Line",
110
+ impact="Simple shared line between 2 devices",
111
+ )
112
+ )
113
+
114
+ options.extend([
115
+ DecisionOption(
116
+ id="call_park",
117
+ label="Call Park + BLF",
118
+ impact="Loses real-time shared state",
119
+ ),
120
+ DecisionOption(
121
+ id="skip",
122
+ label="Skip",
123
+ impact="DN migrated only on primary owner's device",
124
+ ),
125
+ ])
126
+
127
+ context = {
128
+ "dn": dn_display,
129
+ "partition": route_partition_name,
130
+ "device_count": device_count,
131
+ "owner_ids": sorted(owner_ids),
132
+ "dn_canonical_id": dn_id,
133
+ }
134
+
135
+ affected_objects = [dn_id] + sorted(device_ids)
136
+
137
+ decision = self._create_decision(
138
+ store=store,
139
+ decision_type=DecisionType.SHARED_LINE_COMPLEX,
140
+ severity=severity,
141
+ summary=(
142
+ f"DN {dn_display} shared across {device_count} devices "
143
+ f"with {unique_owners} unique owners"
144
+ ),
145
+ context=context,
146
+ options=options,
147
+ affected_objects=affected_objects,
148
+ )
149
+ decisions.append(decision)
150
+
151
+ return decisions
152
+
153
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
154
+ """Deterministic fingerprint from DN, partition, owners, and device count.
155
+
156
+ Includes:
157
+ - dn (the shared extension or pattern)
158
+ - partition (route partition — scope of the DN)
159
+ - sorted owner_ids (all users who share this DN)
160
+ - device_count (number of devices — change invalidates resolution)
161
+
162
+ (from 07-idempotency-resumability.md lines 30-43)
163
+ """
164
+ return self._hash_fingerprint({
165
+ "type": decision_type.value,
166
+ "dn": context.get("dn"),
167
+ "partition": context.get("partition"),
168
+ "owner_ids": sorted(context.get("owner_ids", [])),
169
+ "device_count": context.get("device_count"),
170
+ })
@@ -0,0 +1,267 @@
1
+ """Voicemail compatibility analyzer for CUCM-to-Webex migration.
2
+
3
+ Finds voicemail profiles with Unity Connection features that lack
4
+ Webex Calling equivalents (fax-to-voicemail, non-standard message
5
+ storage, complex transfer routing).
6
+
7
+ VOICEMAIL_INCOMPATIBLE is a mapper-owned decision type — the
8
+ voicemail_mapper already produces these during transform. This
9
+ analyzer backfills any profiles the mapper missed or that were
10
+ added after initial mapping.
11
+
12
+ (from 03-conflict-detection-engine.md, linter pattern)
13
+ (from phase-06-analyzers.md, Decision Ownership Table — VOICEMAIL_INCOMPATIBLE is mapper-owned)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from wxcli.migration.models import DecisionOption, DecisionType
21
+ from wxcli.migration.store import MigrationStore
22
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Incompatibility detection helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _has_fax_message(profile: dict[str, Any]) -> str | None:
30
+ """Return issue string if fax-to-voicemail is configured."""
31
+ fax = profile.get("fax_message")
32
+ if fax:
33
+ return "fax_message: Webex has no fax-to-voicemail"
34
+ return None
35
+
36
+
37
+ def _has_nonstandard_storage(profile: dict[str, Any]) -> str | None:
38
+ """Return issue string if message_storage uses non-standard settings."""
39
+ storage = profile.get("message_storage")
40
+ if storage is None:
41
+ return None
42
+ if isinstance(storage, str):
43
+ normalized = storage.strip().lower()
44
+ if normalized in ("", "standard", "default", "mailbox"):
45
+ return None
46
+ return f"message_storage '{storage}': non-standard storage not supported in Webex"
47
+ if isinstance(storage, dict):
48
+ # Dict-form storage — check for MWI-only or external indicators
49
+ storage_type = storage.get("type", "").lower()
50
+ if storage_type in ("mwi_only", "mwi-only", "external"):
51
+ return f"message_storage type '{storage_type}': not supported in Webex"
52
+ # Non-empty dict with unexpected keys suggests custom config
53
+ if storage and storage_type not in ("", "standard", "default", "mailbox"):
54
+ return f"message_storage type '{storage_type}': non-standard storage not supported in Webex"
55
+ return None
56
+
57
+
58
+ def _has_complex_transfer(profile: dict[str, Any]) -> str | None:
59
+ """Return issue string if transfer_to_number has complex routing."""
60
+ transfer = profile.get("transfer_to_number")
61
+ if transfer is None:
62
+ return None
63
+ if isinstance(transfer, str) and transfer.strip():
64
+ # Simple transfer number — this maps to Webex forwarding, minor gap
65
+ return None
66
+ if isinstance(transfer, dict):
67
+ # Dict-form transfer implies complex routing rules
68
+ rules = transfer.get("rules") or transfer.get("conditions")
69
+ if rules:
70
+ return "transfer_to_number: complex routing rules cannot map to Webex"
71
+ # Has a transfer config dict but no complex rules — check for non-empty
72
+ transfer_type = transfer.get("type", "")
73
+ if transfer_type and transfer_type.lower() not in ("", "simple", "direct"):
74
+ return f"transfer_to_number type '{transfer_type}': complex transfer not supported in Webex"
75
+ return None
76
+
77
+
78
+ def _check_warnings(profile: dict[str, Any]) -> list[str]:
79
+ """Extract voicemail-related warnings from the object's warnings list."""
80
+ warnings = profile.get("warnings") or []
81
+ issues: list[str] = []
82
+ for w in warnings:
83
+ if not isinstance(w, str):
84
+ continue
85
+ w_lower = w.lower()
86
+ if any(kw in w_lower for kw in (
87
+ "voicemail", "unity", "fax", "message storage",
88
+ "not supported", "incompatible", "mwi",
89
+ )):
90
+ issues.append(f"warning: {w}")
91
+ return issues
92
+
93
+
94
+ # Severity assignment: fax and complex transfer are MEDIUM;
95
+ # non-standard storage and warning-only issues are MEDIUM;
96
+ # simple warnings are LOW.
97
+ _HIGH_KEYWORDS = frozenset() # nothing is HIGH for voicemail incompatibilities
98
+ _MEDIUM_KEYWORDS = frozenset((
99
+ "fax_message", "message_storage", "transfer_to_number",
100
+ ))
101
+
102
+
103
+ class VoicemailCompatibilityAnalyzer(Analyzer):
104
+ """Detects voicemail profiles with Unity Connection features that
105
+ lack Webex Calling equivalents.
106
+
107
+ Checks each voicemail_profile object for:
108
+ - fax_message configured (Webex has no fax-to-voicemail)
109
+ - message_storage with MWI-only or external storage
110
+ - transfer_to_number with complex routing rules
111
+ - Warnings on the object indicating incompatibility
112
+
113
+ Skips profiles already covered by voicemail_mapper decisions.
114
+
115
+ (from 03-conflict-detection-engine.md, voicemail compatibility linter)
116
+ """
117
+
118
+ name = "voicemail_compatibility"
119
+ decision_types = [DecisionType.VOICEMAIL_INCOMPATIBLE]
120
+ depends_on: list[str] = []
121
+
122
+ def analyze(self, store: MigrationStore) -> list[Decision]:
123
+ """Sweep voicemail profiles for incompatible features."""
124
+ # Existing mapper-produced decisions indexed by canonical_id
125
+ existing = self._get_existing_decisions_for_type(
126
+ store, DecisionType.VOICEMAIL_INCOMPATIBLE
127
+ )
128
+
129
+ decisions: list[Decision] = []
130
+ profiles = store.get_objects("voicemail_profile")
131
+
132
+ for profile in profiles:
133
+ canonical_id = profile.get("canonical_id", "")
134
+
135
+ # Skip if mapper already produced a decision for this profile
136
+ if canonical_id in existing:
137
+ continue
138
+
139
+ incompatible_features = self._detect_incompatibilities(profile)
140
+ if not incompatible_features:
141
+ continue
142
+
143
+ severity = self._determine_severity(incompatible_features)
144
+ profile_name = (
145
+ profile.get("cucm_voicemail_profile_name")
146
+ or profile.get("name")
147
+ or canonical_id
148
+ )
149
+ user_canonical_id = profile.get("user_canonical_id", "")
150
+
151
+ summary = (
152
+ f"Voicemail profile '{profile_name}' has {len(incompatible_features)} "
153
+ f"feature(s) incompatible with Webex: "
154
+ f"{', '.join(self._feature_names(incompatible_features))}"
155
+ )
156
+
157
+ context = {
158
+ "canonical_id": canonical_id,
159
+ "profile_name": profile_name,
160
+ "user_canonical_id": user_canonical_id,
161
+ "incompatible_features": incompatible_features,
162
+ }
163
+
164
+ options = [
165
+ DecisionOption(
166
+ id="accept",
167
+ label="Accept fidelity loss",
168
+ impact=(
169
+ f"Voicemail profile '{profile_name}' migrated without "
170
+ f"{', '.join(self._feature_names(incompatible_features))}"
171
+ ),
172
+ ),
173
+ DecisionOption(
174
+ id="manual",
175
+ label="Configure manually post-migration",
176
+ impact=(
177
+ f"Voicemail profile '{profile_name}' flagged for manual "
178
+ f"post-migration configuration"
179
+ ),
180
+ ),
181
+ DecisionOption(
182
+ id="skip",
183
+ label="Skip this profile",
184
+ impact=f"Voicemail profile '{profile_name}' excluded from migration",
185
+ ),
186
+ ]
187
+
188
+ affected = [canonical_id]
189
+ if user_canonical_id:
190
+ affected.append(user_canonical_id)
191
+
192
+ decision = self._create_decision(
193
+ store=store,
194
+ decision_type=DecisionType.VOICEMAIL_INCOMPATIBLE,
195
+ severity=severity,
196
+ summary=summary,
197
+ context=context,
198
+ options=options,
199
+ affected_objects=affected,
200
+ )
201
+ decisions.append(decision)
202
+
203
+ return decisions
204
+
205
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
206
+ """Deterministic fingerprint from canonical_id + sorted incompatible feature names."""
207
+ features = context.get("incompatible_features", [])
208
+ return self._hash_fingerprint({
209
+ "type": decision_type.value,
210
+ "canonical_id": context.get("canonical_id", ""),
211
+ "incompatible_features": sorted(self._feature_names(features)),
212
+ })
213
+
214
+ # ------------------------------------------------------------------
215
+ # Internal helpers
216
+ # ------------------------------------------------------------------
217
+
218
+ @staticmethod
219
+ def _detect_incompatibilities(profile: dict[str, Any]) -> list[str]:
220
+ """Return list of issue description strings for all detected incompatibilities."""
221
+ issues: list[str] = []
222
+
223
+ fax_issue = _has_fax_message(profile)
224
+ if fax_issue:
225
+ issues.append(fax_issue)
226
+
227
+ storage_issue = _has_nonstandard_storage(profile)
228
+ if storage_issue:
229
+ issues.append(storage_issue)
230
+
231
+ transfer_issue = _has_complex_transfer(profile)
232
+ if transfer_issue:
233
+ issues.append(transfer_issue)
234
+
235
+ warning_issues = _check_warnings(profile)
236
+ issues.extend(warning_issues)
237
+
238
+ return issues
239
+
240
+ @staticmethod
241
+ def _feature_names(issues: list[str]) -> list[str]:
242
+ """Extract short feature names from issue description strings.
243
+
244
+ E.g., "fax_message: Webex has no fax-to-voicemail" -> "fax_message"
245
+ """
246
+ names: list[str] = []
247
+ for issue in issues:
248
+ if ":" in issue:
249
+ name = issue.split(":")[0].strip()
250
+ else:
251
+ name = issue[:40]
252
+ names.append(name)
253
+ return names
254
+
255
+ @staticmethod
256
+ def _determine_severity(issues: list[str]) -> str:
257
+ """Determine decision severity based on the incompatible features found.
258
+
259
+ MEDIUM for feature-level incompatibilities (fax, storage, transfer).
260
+ LOW for warning-only issues.
261
+ """
262
+ for issue in issues:
263
+ prefix = issue.split(":")[0].strip() if ":" in issue else ""
264
+ if prefix in _MEDIUM_KEYWORDS:
265
+ return "MEDIUM"
266
+ # All issues are warning-based
267
+ return "LOW"
@@ -0,0 +1,212 @@
1
+ """Workspace license tier analyzer for CUCM-to-Webex migration.
2
+
3
+ Determines whether each workspace needs a Workspace or Professional Workspace
4
+ Webex Calling license based on its configured features.
5
+
6
+ WORKSPACE_LICENSE_TIER is a mapper-owned decision type — the
7
+ workspace_mapper may already produce these during transform.
8
+ This analyzer catches workspaces the mapper didn't cover and
9
+ workspaces whose feature profile suggests a tier mismatch.
10
+
11
+ (from 03-conflict-detection-engine.md, linter pattern)
12
+ (from phase-06-analyzers.md, Decision Ownership Table — WORKSPACE_LICENSE_TIER 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
+ # Feature indicators that suggest Professional Workspace license is needed
26
+ # ---------------------------------------------------------------------------
27
+ # Each entry: (field_name, check_fn, feature_label)
28
+ # check_fn receives the field value and returns True if the feature IS present.
29
+
30
+ def _has_value(val: Any) -> bool:
31
+ """True if the value is not None and not empty/falsy."""
32
+ if val is None:
33
+ return False
34
+ if isinstance(val, (str, list, dict)) and not val:
35
+ return False
36
+ return True
37
+
38
+
39
+ def _is_hotdesking_on(val: Any) -> bool:
40
+ return isinstance(val, str) and val.lower() == "on"
41
+
42
+
43
+ _PROFESSIONAL_INDICATORS: list[tuple[str, Any, str]] = [
44
+ ("hotdesking_status", _is_hotdesking_on, "hot-desking enabled"),
45
+ ("phone_number", _has_value, "DID phone number assigned"),
46
+ ]
47
+
48
+ # Features detected via cross-references (relationship, label)
49
+ _XREF_INDICATORS: list[tuple[str, str]] = [
50
+ ("workspace_has_voicemail", "voicemail configured"),
51
+ ("workspace_has_call_forwarding", "call forwarding configured"),
52
+ ("workspace_has_shared_line", "shared line appearance"),
53
+ ]
54
+
55
+
56
+ class WorkspaceLicenseAnalyzer(Analyzer):
57
+ """Determines Workspace vs Professional Workspace license for workspaces.
58
+
59
+ Webex Calling workspaces can be provisioned with Workspace or
60
+ Professional Workspace licenses. Several features require Professional Workspace:
61
+ - Hot-desking
62
+ - DID phone numbers
63
+ - Voicemail
64
+ - Call forwarding
65
+ - Shared line appearances
66
+ - Most /telephony/config/workspaces/ settings
67
+
68
+ The workspace_mapper sets ``license_tier`` during transform when the
69
+ answer is obvious. This analyzer catches workspaces where:
70
+ 1. The mapper didn't set a tier (license_tier is None)
71
+ 2. The workspace has feature indicators that create ambiguity
72
+
73
+ (from docs/reference/devices-workspaces.md, Workspace vs Professional Workspace)
74
+ """
75
+
76
+ name = "workspace_license"
77
+ decision_types = [DecisionType.WORKSPACE_LICENSE_TIER]
78
+
79
+ def analyze(self, store: MigrationStore) -> list[Decision]:
80
+ """Sweep workspaces for undecided or ambiguous license tiers."""
81
+ # Existing mapper-produced decisions indexed by canonical_id
82
+ existing = self._get_existing_decisions_for_type(
83
+ store, DecisionType.WORKSPACE_LICENSE_TIER
84
+ )
85
+
86
+ decisions: list[Decision] = []
87
+ workspaces = store.get_objects("workspace")
88
+
89
+ for ws in workspaces:
90
+ canonical_id = ws.get("canonical_id", "")
91
+
92
+ # Skip workspaces the mapper already produced a decision for
93
+ if canonical_id in existing:
94
+ continue
95
+
96
+ license_tier = ws.get("license_tier")
97
+ features_detected = self._detect_features(ws, store, canonical_id)
98
+
99
+ # Case 1: mapper already set a definite tier and no conflicting features
100
+ if license_tier is not None and not features_detected:
101
+ continue
102
+
103
+ # Case 2: tier is set but features suggest it might be wrong
104
+ # (e.g., mapper said Workspace but workspace has voicemail)
105
+ if license_tier == "Workspace" and features_detected:
106
+ summary = (
107
+ f"Workspace '{ws.get('display_name', canonical_id)}' set to Workspace "
108
+ f"but has Professional Workspace features: {', '.join(features_detected)}"
109
+ )
110
+ # Case 3: no tier set at all
111
+ elif license_tier is None:
112
+ if features_detected:
113
+ summary = (
114
+ f"Workspace '{ws.get('display_name', canonical_id)}' needs license "
115
+ f"tier — detected features: {', '.join(features_detected)}"
116
+ )
117
+ else:
118
+ summary = (
119
+ f"Workspace '{ws.get('display_name', canonical_id)}' needs license "
120
+ f"tier assignment (no feature indicators detected)"
121
+ )
122
+ else:
123
+ # Professional Workspace tier set, features present — consistent, no decision needed
124
+ continue
125
+
126
+ context = {
127
+ "canonical_id": canonical_id,
128
+ "display_name": ws.get("display_name"),
129
+ "license_tier": license_tier,
130
+ "features_detected": features_detected,
131
+ }
132
+
133
+ options = [
134
+ DecisionOption(
135
+ id="workspace",
136
+ label="Workspace",
137
+ impact="Workspace license; limited to basic calling features",
138
+ ),
139
+ DecisionOption(
140
+ id="professional",
141
+ label="Professional Workspace",
142
+ impact="Professional Workspace license; full calling features including voicemail, forwarding, hot-desking",
143
+ ),
144
+ DecisionOption(
145
+ id="skip",
146
+ label="Skip this workspace",
147
+ impact="1 workspace excluded from migration",
148
+ ),
149
+ ]
150
+
151
+ decision = self._create_decision(
152
+ store=store,
153
+ decision_type=DecisionType.WORKSPACE_LICENSE_TIER,
154
+ severity="MEDIUM",
155
+ summary=summary,
156
+ context=context,
157
+ options=options,
158
+ affected_objects=[canonical_id],
159
+ )
160
+ decisions.append(decision)
161
+
162
+ return decisions
163
+
164
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
165
+ """Deterministic fingerprint from canonical_id + license tier + detected features.
166
+
167
+ Includes features_detected so that discovering new Professional Workspace-tier
168
+ features invalidates a previous "Workspace" resolution.
169
+ (from 07-idempotency-resumability.md: include data whose change would
170
+ invalidate a previous resolution)
171
+ """
172
+ return self._hash_fingerprint({
173
+ "type": decision_type.value,
174
+ "canonical_id": context.get("canonical_id", ""),
175
+ "license_tier": context.get("license_tier"),
176
+ "features_detected": sorted(context.get("features_detected", [])),
177
+ })
178
+
179
+ # ------------------------------------------------------------------
180
+ # Internal helpers
181
+ # ------------------------------------------------------------------
182
+
183
+ @staticmethod
184
+ def _detect_features(
185
+ ws: dict[str, Any],
186
+ store: MigrationStore,
187
+ canonical_id: str,
188
+ ) -> list[str]:
189
+ """Detect features that suggest Professional Workspace license is needed.
190
+
191
+ Checks both workspace data fields and cross-references to
192
+ related objects (voicemail profiles, call forwarding, shared lines).
193
+ """
194
+ features: list[str] = []
195
+
196
+ # Check data fields
197
+ for field_name, check_fn, label in _PROFESSIONAL_INDICATORS:
198
+ if check_fn(ws.get(field_name)):
199
+ features.append(label)
200
+
201
+ # Check multiple line appearances (>1 line suggests shared/complex config)
202
+ line_refs = store.find_cross_refs(canonical_id, "workspace_has_line")
203
+ if len(line_refs) > 1:
204
+ features.append(f"multiple lines ({len(line_refs)})")
205
+
206
+ # Check cross-reference indicators
207
+ for relationship, label in _XREF_INDICATORS:
208
+ refs = store.find_cross_refs(canonical_id, relationship)
209
+ if refs:
210
+ features.append(label)
211
+
212
+ return features