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,190 @@
1
+ """Base class for all transform mappers.
2
+
3
+ Provides the Mapper ABC with map() abstract method, decision creation helpers,
4
+ fingerprint generation, and standard decision option builders.
5
+
6
+ (from 03b-transform-mappers.md, shared patterns section §13)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from abc import ABC, abstractmethod
14
+ from datetime import datetime, timezone
15
+ from typing import Any
16
+
17
+ from wxcli.migration.models import (
18
+ CanonicalUser,
19
+ Decision,
20
+ DecisionOption,
21
+ DecisionType,
22
+ MapperResult,
23
+ Provenance,
24
+ )
25
+ from wxcli.migration.store import MigrationStore
26
+
27
+
28
+ class Mapper(ABC):
29
+ """Base class for all transform mappers.
30
+
31
+ Each mapper reads CUCM canonical objects from the store, produces
32
+ Webex canonical objects, and returns decisions for anything it
33
+ can't cleanly map.
34
+
35
+ (from 03b-transform-mappers.md, shared patterns — Mapper base class)
36
+ """
37
+
38
+ name: str = ""
39
+ depends_on: list[str] = []
40
+
41
+ @abstractmethod
42
+ def map(self, store: MigrationStore) -> MapperResult:
43
+ """Read CUCM canonical objects, produce Webex canonical objects + decisions."""
44
+ ...
45
+
46
+ def _create_decision(
47
+ self,
48
+ store: MigrationStore,
49
+ decision_type: DecisionType,
50
+ severity: str,
51
+ summary: str,
52
+ context: dict[str, Any],
53
+ options: list[DecisionOption],
54
+ affected_objects: list[str],
55
+ ) -> Decision:
56
+ """Helper to create a well-formed Decision with auto-generated ID and fingerprint.
57
+
58
+ (from 03b-transform-mappers.md, shared patterns — _create_decision helper)
59
+ """
60
+ return Decision(
61
+ decision_id=store.next_decision_id(),
62
+ type=decision_type,
63
+ severity=severity,
64
+ summary=summary,
65
+ context=context,
66
+ options=options,
67
+ affected_objects=affected_objects,
68
+ fingerprint=self._fingerprint(decision_type, context),
69
+ run_id=store.current_run_id,
70
+ )
71
+
72
+ def _fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
73
+ """Deterministic fingerprint for idempotent decision identity.
74
+
75
+ (from 03b-transform-mappers.md, shared patterns — _fingerprint helper)
76
+ """
77
+ key_data = json.dumps({"type": decision_type.value, **context}, sort_keys=True)
78
+ return hashlib.sha256(key_data.encode()).hexdigest()[:16]
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Decision option builder helpers
83
+ # (from 03b-transform-mappers.md, shared patterns — Decision Option Builder)
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def skip_option(impact: str = "Object not migrated") -> DecisionOption:
88
+ """Create a 'Skip' decision option."""
89
+ return DecisionOption(id="skip", label="Skip", impact=impact)
90
+
91
+
92
+ def manual_option(impact: str = "Requires manual configuration post-migration") -> DecisionOption:
93
+ """Create a 'Manual' decision option."""
94
+ return DecisionOption(id="manual", label="Manual", impact=impact)
95
+
96
+
97
+ def accept_option(impact: str) -> DecisionOption:
98
+ """Create an 'Accept fidelity loss' decision option."""
99
+ return DecisionOption(id="accept", label="Accept fidelity loss", impact=impact)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Store serialization helper
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ def decision_to_store_dict(decision: Decision) -> dict[str, Any]:
108
+ """Convert a Decision Pydantic model to the dict format store.save_decision() expects.
109
+
110
+ The store expects:
111
+ - type as string value (e.g., "LOCATION_AMBIGUOUS"), not the enum member
112
+ - options as list of dicts (not DecisionOption models)
113
+ - context as dict (not JSON string — the store handles serialization)
114
+
115
+ affected_objects is embedded in context (as _affected_objects) since the
116
+ decisions table has no dedicated column. merge_decisions() in Phase 10
117
+ can read it from context when needed.
118
+ """
119
+ ctx = dict(decision.context)
120
+ if decision.affected_objects:
121
+ ctx["_affected_objects"] = decision.affected_objects
122
+ return {
123
+ "decision_id": decision.decision_id,
124
+ "type": decision.type.value,
125
+ "severity": decision.severity,
126
+ "summary": decision.summary,
127
+ "context": ctx,
128
+ "options": [opt.model_dump() for opt in decision.options],
129
+ "chosen_option": decision.chosen_option,
130
+ "resolved_at": decision.resolved_at,
131
+ "resolved_by": decision.resolved_by,
132
+ "fingerprint": decision.fingerprint,
133
+ "run_id": decision.run_id,
134
+ "recommendation": decision.recommendation,
135
+ "recommendation_reasoning": decision.recommendation_reasoning,
136
+ }
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Shared mapper utilities (DRY — used by all 9 mappers)
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def extract_provenance(data: dict[str, Any]) -> Provenance:
145
+ """Extract Provenance from a stored object dict.
146
+
147
+ Handles both Pydantic-serialized dicts (from store.get_objects) and
148
+ raw dicts. Gracefully handles missing or malformed extracted_at.
149
+ """
150
+ prov = data.get("provenance", {})
151
+ extracted_at = prov.get("extracted_at")
152
+ if isinstance(extracted_at, str):
153
+ try:
154
+ extracted_at = datetime.fromisoformat(extracted_at)
155
+ except (ValueError, TypeError):
156
+ extracted_at = datetime.now(timezone.utc)
157
+ elif extracted_at is None:
158
+ extracted_at = datetime.now(timezone.utc)
159
+
160
+ return Provenance(
161
+ source_system=prov.get("source_system", "cucm"),
162
+ source_id=prov.get("source_id", ""),
163
+ source_name=prov.get("source_name", ""),
164
+ cluster=prov.get("cluster"),
165
+ extracted_at=extracted_at,
166
+ cucm_version=prov.get("cucm_version"),
167
+ )
168
+
169
+
170
+ def enrich_user(store: MigrationStore, user_id: str, **fields: Any) -> None:
171
+ """Update fields on a CanonicalUser in the store.
172
+
173
+ Re-reads the user dict, merges the provided fields, reconstructs
174
+ a CanonicalUser (filtering to known model fields), and upserts.
175
+ Used by enrichment mappers (VoicemailMapper, CallSettingsMapper).
176
+ """
177
+ user_data = store.get_object(user_id)
178
+ if not user_data:
179
+ return
180
+ user_data.update(fields)
181
+ enriched = CanonicalUser(**{
182
+ k: v for k, v in user_data.items()
183
+ if k in CanonicalUser.model_fields
184
+ })
185
+ store.upsert_object(enriched)
186
+
187
+
188
+ def hash_id(value: str) -> str:
189
+ """Generate a deterministic short hash for canonical IDs."""
190
+ return hashlib.sha256(value.encode()).hexdigest()[:12]
@@ -0,0 +1,186 @@
1
+ """ButtonTemplateMapper: CUCM Phone Button Templates -> Webex Line Key Templates.
2
+
3
+ Maps CUCM button feature types to Webex LineKeyType values. Determines KEM
4
+ boundaries from model-specific line key counts. Produces CanonicalLineKeyTemplate
5
+ objects and BUTTON_UNMAPPABLE decisions for unmappable buttons.
6
+
7
+ (from tier2-phase2-phone-config-design.md S4.1)
8
+
9
+ Cross-ref reads:
10
+ phone_uses_button_template (Phone -> ButtonTemplate) -- usage count
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ from typing import Any
17
+
18
+ from wxcli.migration.models import (
19
+ CanonicalLineKeyTemplate,
20
+ DecisionOption,
21
+ DecisionType,
22
+ MapperResult,
23
+ MigrationStatus,
24
+ )
25
+ from wxcli.migration.store import MigrationStore
26
+ from wxcli.migration.transform.mappers.base import (
27
+ Mapper,
28
+ decision_to_store_dict,
29
+ extract_provenance,
30
+ skip_option,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # CUCM button feature -> Webex LineKeyType
36
+ CUCM_BUTTON_TO_WEBEX: dict[str, str | None] = {
37
+ "Line": "PRIMARY_LINE",
38
+ "Speed Dial": "SPEED_DIAL",
39
+ "Busy Lamp Field": "MONITOR",
40
+ "Speed Dial BLF": "MONITOR", # AXL getPhoneButtonTemplate returns this name
41
+ "Call Park": "CALL_PARK_EXTENSION",
42
+ "Call Park BLF": "CALL_PARK_EXTENSION",
43
+ "Abbreviated Dial": "SPEED_DIAL",
44
+ "None": "OPEN",
45
+ "Unassigned": "OPEN",
46
+ # Unmappable -- return None
47
+ "Intercom": None,
48
+ "Service URL": None,
49
+ "Privacy": None,
50
+ "Malicious Call ID": None,
51
+ "Quality Report Tool": None,
52
+ "Do Not Disturb": None,
53
+ "Feature": None,
54
+ }
55
+
56
+ # Max line key count per phone model (buttons beyond this are KEM keys)
57
+ from wxcli.migration.phone_models import MODEL_LINE_KEY_COUNTS as _MODEL_LINE_KEY_COUNTS
58
+
59
+ # Regex to extract 4-digit model number from base template name
60
+ _MODEL_RE = re.compile(r"(\d{4})")
61
+
62
+
63
+ def _parse_model_from_base_template(base_template: str | None) -> str | None:
64
+ """Extract the 4-digit phone model from a CUCM base template name.
65
+
66
+ E.g. "Standard 8845" -> "8845", "Universal Phone Template" -> None
67
+ """
68
+ if not base_template:
69
+ return None
70
+ match = _MODEL_RE.search(base_template)
71
+ return match.group(1) if match else None
72
+
73
+
74
+ class ButtonTemplateMapper(Mapper):
75
+ """Maps CUCM Phone Button Templates to Webex Line Key Templates."""
76
+
77
+ name = "button_template_mapper"
78
+ depends_on = ["device_mapper"]
79
+
80
+ def map(self, store: MigrationStore) -> MapperResult:
81
+ result = MapperResult()
82
+
83
+ for tmpl_data in store.get_objects("button_template"):
84
+ tmpl_id = tmpl_data["canonical_id"]
85
+ state = tmpl_data.get("pre_migration_state") or {}
86
+ name = state.get("name", tmpl_id.split(":", 1)[-1])
87
+ base_template = state.get("base_template")
88
+ buttons = state.get("buttons", [])
89
+
90
+ # Count phones using this template
91
+ phone_refs = [
92
+ r["from_id"]
93
+ for r in store.get_cross_refs(
94
+ to_id=tmpl_id, relationship="phone_uses_button_template"
95
+ )
96
+ ]
97
+ phones_using = len(phone_refs)
98
+
99
+ # Determine model and max line keys
100
+ model_num = (
101
+ _parse_model_from_base_template(base_template)
102
+ or _parse_model_from_base_template(name)
103
+ )
104
+ max_keys = _MODEL_LINE_KEY_COUNTS.get(model_num, 999)
105
+
106
+ line_keys: list[dict[str, Any]] = []
107
+ kem_keys: list[dict[str, Any]] = []
108
+ unmapped: list[dict[str, Any]] = []
109
+
110
+ for btn in buttons:
111
+ idx = btn.get("index", 0)
112
+ feature = btn.get("feature", "")
113
+ webex_type = CUCM_BUTTON_TO_WEBEX.get(feature)
114
+
115
+ if webex_type is None and feature:
116
+ # Unknown or unmappable feature
117
+ unmapped.append({"index": idx, "feature": feature})
118
+ continue
119
+
120
+ if not webex_type:
121
+ webex_type = "OPEN"
122
+
123
+ entry = {"index": idx, "key_type": webex_type}
124
+
125
+ if idx <= max_keys:
126
+ line_keys.append(entry)
127
+ else:
128
+ kem_keys.append(entry)
129
+
130
+ # Determine KEM module type from key count
131
+ kem_module_type = None
132
+ if kem_keys:
133
+ kem_count = len(kem_keys)
134
+ if kem_count <= 14:
135
+ kem_module_type = "KEM_14_KEYS"
136
+ elif kem_count <= 18:
137
+ kem_module_type = "KEM_18_KEYS"
138
+ else:
139
+ kem_module_type = "KEM_20_KEYS"
140
+
141
+ lkt = CanonicalLineKeyTemplate(
142
+ canonical_id=f"line_key_template:{name}",
143
+ provenance=extract_provenance(tmpl_data),
144
+ status=MigrationStatus.ANALYZED,
145
+ name=name,
146
+ cucm_template_name=name,
147
+ device_model=f"DMS Cisco {model_num}" if model_num else None,
148
+ line_keys=line_keys,
149
+ kem_module_type=kem_module_type,
150
+ kem_keys=kem_keys,
151
+ unmapped_buttons=unmapped,
152
+ phones_using=phones_using,
153
+ )
154
+ store.upsert_object(lkt)
155
+ result.objects_created += 1
156
+
157
+ # Decision for unmappable buttons (only if phones use this template)
158
+ if unmapped and phones_using > 0:
159
+ features = list({u["feature"] for u in unmapped})
160
+ decision = self._create_decision(
161
+ store=store,
162
+ decision_type=DecisionType.BUTTON_UNMAPPABLE,
163
+ severity="LOW",
164
+ summary=(
165
+ f"Template '{name}' has {len(unmapped)} button(s) with no Webex "
166
+ f"equivalent: {', '.join(features)} ({phones_using} phones affected)"
167
+ ),
168
+ context={
169
+ "template_name": name,
170
+ "unmapped_features": features,
171
+ "phones_using": phones_using,
172
+ },
173
+ options=[
174
+ DecisionOption(
175
+ id="accept_loss",
176
+ label="Accept loss",
177
+ impact="Accept -- these button types don't exist in Webex",
178
+ ),
179
+ skip_option("Skip template migration for these phones"),
180
+ ],
181
+ affected_objects=[lkt.canonical_id],
182
+ )
183
+ store.save_decision(decision_to_store_dict(decision))
184
+ result.decisions.append(decision)
185
+
186
+ return result
@@ -0,0 +1,228 @@
1
+ """CallForwardingMapper — CUCM per-line forwarding → Webex per-person call forwarding.
2
+
3
+ Reads phone objects from the store (which contain enriched line entries with
4
+ call forwarding fields from getLine), resolves user ownership via
5
+ device_owned_by_user cross-ref, and produces CanonicalCallForwarding objects.
6
+
7
+ CUCM has 10 forwarding variants. Webex supports 3 (CFA, CFB, CFNA).
8
+ The remaining 5 CUCM-only variants (BusyInt, NoAnswerInt, NoCoverage,
9
+ OnFailure, NotRegistered) produce FORWARDING_LOSSY decisions.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import math
16
+ from typing import Any
17
+
18
+ from wxcli.migration.models import (
19
+ CanonicalCallForwarding,
20
+ DecisionType,
21
+ MigrationStatus,
22
+ )
23
+ from wxcli.migration.store import MigrationStore
24
+ from wxcli.migration.models import MapperResult
25
+ from wxcli.migration.transform.mappers.base import (
26
+ Mapper,
27
+ accept_option,
28
+ extract_provenance,
29
+ skip_option,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # CUCM-only forwarding variants that have no Webex equivalent
35
+ _LOSSY_VARIANTS = (
36
+ "callForwardBusyInt",
37
+ "callForwardNoAnswerInt",
38
+ "callForwardNoCoverage",
39
+ "callForwardNoCoverageInt",
40
+ "callForwardOnFailure",
41
+ "callForwardNotRegistered",
42
+ "callForwardNotRegisteredInt",
43
+ )
44
+
45
+
46
+ def _is_forwarding_active(fwd: dict | None) -> bool:
47
+ """Check if a CUCM forwarding setting is actively configured."""
48
+ if not fwd or not isinstance(fwd, dict):
49
+ return False
50
+ dest = fwd.get("destination")
51
+ fwd_to_vm = fwd.get("forwardToVoiceMail")
52
+ # Active if there's a non-empty destination or it forwards to voicemail
53
+ return bool(dest) or (fwd_to_vm in ("true", True))
54
+
55
+
56
+ def _duration_to_rings(duration_ms: int | str | None) -> int | None:
57
+ """Convert CUCM duration (seconds) to Webex ring count.
58
+
59
+ CUCM stores call forward no answer timer in seconds.
60
+ Webex uses ring count: divide by 6, round up, clamp to 2-20.
61
+ """
62
+ if duration_ms is None:
63
+ return None
64
+ try:
65
+ val = int(duration_ms)
66
+ except (ValueError, TypeError):
67
+ return None
68
+ if val <= 0:
69
+ return None
70
+ rings = math.ceil(val / 6)
71
+ return max(2, min(20, rings))
72
+
73
+
74
+ class CallForwardingMapper(Mapper):
75
+ """Map CUCM per-line call forwarding to Webex per-person call forwarding.
76
+
77
+ Tier 2 expansion: adds CanonicalCallForwarding objects and
78
+ FORWARDING_LOSSY decisions for CUCM-only variants.
79
+ """
80
+
81
+ name = "call_forwarding_mapper"
82
+ depends_on = ["user_mapper", "line_mapper"]
83
+
84
+ def map(self, store: MigrationStore) -> MapperResult:
85
+ result = MapperResult()
86
+
87
+ # Iterate phones (which contain enriched line entries)
88
+ for phone_data in store.get_objects("phone"):
89
+ phone_id = phone_data["canonical_id"]
90
+ state = phone_data.get("pre_migration_state") or {}
91
+ lines = state.get("lines", [])
92
+ if not lines:
93
+ continue
94
+
95
+ # Resolve owner user via cross-ref
96
+ user_refs = store.find_cross_refs(phone_id, "device_owned_by_user")
97
+ if not user_refs:
98
+ continue
99
+ user_id = user_refs[0]
100
+
101
+ # Check if we already created forwarding for this user (multi-device)
102
+ user_name = user_id.split(":", 1)[-1]
103
+ existing = store.get_object(f"call_forwarding:{user_name}")
104
+ if existing:
105
+ continue
106
+
107
+ # Use primary line (index 1 or first line)
108
+ primary_line = self._get_primary_line(lines)
109
+ if not primary_line:
110
+ continue
111
+
112
+ # Build canonical call forwarding
113
+ cf = self._build_call_forwarding(store, user_id, phone_data, primary_line)
114
+ store.upsert_object(cf)
115
+ result.objects_created += 1
116
+
117
+ # Write cross-ref
118
+ store.add_cross_ref(user_id, cf.canonical_id, "user_has_call_forwarding")
119
+
120
+ # Check for lossy CUCM-only variants
121
+ lossy_variants = self._detect_lossy_variants(primary_line)
122
+ if lossy_variants:
123
+ decision = self._create_decision(
124
+ store=store,
125
+ decision_type=DecisionType.FORWARDING_LOSSY,
126
+ severity="LOW",
127
+ summary=(
128
+ f"User '{user_name}' has {len(lossy_variants)} "
129
+ f"CUCM-only forwarding variant(s): {', '.join(lossy_variants)}"
130
+ ),
131
+ context={
132
+ "user_id": user_id,
133
+ "lossy_variants": lossy_variants,
134
+ },
135
+ options=[
136
+ accept_option(f"Accept loss of {len(lossy_variants)} CUCM-only forwarding variant(s)"),
137
+ skip_option("Skip call forwarding migration for this user"),
138
+ ],
139
+ affected_objects=[user_id, cf.canonical_id],
140
+ )
141
+ result.decisions.append(decision)
142
+
143
+ return result
144
+
145
+ def _get_primary_line(self, lines: list | dict) -> dict | None:
146
+ """Get the primary line (index 1) from a phone's line list or dict."""
147
+ if not lines:
148
+ return None
149
+ # lines may be a list or a dict (integer-keyed) depending on AXL normalization
150
+ if isinstance(lines, dict):
151
+ # Integer-keyed dict: try key 1 or "1"
152
+ first = lines.get(1) or lines.get("1")
153
+ return first if isinstance(first, dict) else None
154
+ # List form
155
+ for line in lines:
156
+ if isinstance(line, dict):
157
+ idx = line.get("index")
158
+ if str(idx) == "1":
159
+ return line
160
+ # Fallback: first line
161
+ first = lines[0]
162
+ return first if isinstance(first, dict) else None
163
+
164
+ def _build_call_forwarding(
165
+ self,
166
+ store: MigrationStore,
167
+ user_id: str,
168
+ phone_data: dict,
169
+ line: dict,
170
+ ) -> CanonicalCallForwarding:
171
+ """Build a CanonicalCallForwarding from a phone's primary line."""
172
+ user_name = user_id.split(":", 1)[-1]
173
+ prov = extract_provenance(phone_data)
174
+
175
+ # CFA (Call Forward All)
176
+ cfa = line.get("callForwardAll") or {}
177
+ # CFB (Call Forward Busy)
178
+ cfb = line.get("callForwardBusy") or {}
179
+ # CFNA (Call Forward No Answer)
180
+ cfna = line.get("callForwardNoAnswer") or {}
181
+ # CUCM-only variants (preserved for decision context)
182
+ cfb_int = line.get("callForwardBusyInt") or {}
183
+ cfna_int = line.get("callForwardNoAnswerInt") or {}
184
+ cfnc = line.get("callForwardNoCoverage") or {}
185
+ cfnc_int = line.get("callForwardNoCoverageInt") or {}
186
+ cff = line.get("callForwardOnFailure") or {}
187
+ cfnr = line.get("callForwardNotRegistered") or {}
188
+ cfnr_int = line.get("callForwardNotRegisteredInt") or {}
189
+
190
+ return CanonicalCallForwarding(
191
+ canonical_id=f"call_forwarding:{user_name}",
192
+ provenance=prov,
193
+ status=MigrationStatus.ANALYZED,
194
+ user_canonical_id=user_id,
195
+ # CFA
196
+ always_enabled=bool(cfa.get("destination") or cfa.get("forwardToVoiceMail") in ("true", True)),
197
+ always_destination=cfa.get("destination") or None,
198
+ always_to_voicemail=cfa.get("forwardToVoiceMail") in ("true", True),
199
+ # CFB
200
+ busy_enabled=bool(cfb.get("destination") or cfb.get("forwardToVoiceMail") in ("true", True)),
201
+ busy_destination=cfb.get("destination") or None,
202
+ busy_to_voicemail=cfb.get("forwardToVoiceMail") in ("true", True),
203
+ # CFNA
204
+ no_answer_enabled=bool(cfna.get("destination") or cfna.get("forwardToVoiceMail") in ("true", True)),
205
+ no_answer_destination=cfna.get("destination") or None,
206
+ no_answer_to_voicemail=cfna.get("forwardToVoiceMail") in ("true", True),
207
+ no_answer_ring_count=_duration_to_rings(cfna.get("duration")),
208
+ # CUCM-only (preserved in canonical for reporting)
209
+ busy_internal_enabled=_is_forwarding_active(cfb_int),
210
+ busy_internal_destination=cfb_int.get("destination") or None,
211
+ no_answer_internal_enabled=_is_forwarding_active(cfna_int),
212
+ no_answer_internal_destination=cfna_int.get("destination") or None,
213
+ no_coverage_enabled=_is_forwarding_active(cfnc) or _is_forwarding_active(cfnc_int),
214
+ no_coverage_destination=cfnc.get("destination") or cfnc_int.get("destination") or None,
215
+ on_failure_enabled=_is_forwarding_active(cff),
216
+ on_failure_destination=cff.get("destination") or None,
217
+ not_registered_enabled=_is_forwarding_active(cfnr) or _is_forwarding_active(cfnr_int),
218
+ not_registered_destination=cfnr.get("destination") or cfnr_int.get("destination") or None,
219
+ )
220
+
221
+ def _detect_lossy_variants(self, line: dict) -> list[str]:
222
+ """Detect CUCM-only forwarding variants that are actively configured."""
223
+ lossy = []
224
+ for variant in _LOSSY_VARIANTS:
225
+ fwd = line.get(variant)
226
+ if _is_forwarding_active(fwd):
227
+ lossy.append(variant)
228
+ return lossy