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,201 @@
1
+ """SNRMapper — CUCM Remote Destinations → Webex Single Number Reach.
2
+
3
+ Reads remote_destination objects from the store, groups by user,
4
+ resolves user ownership, normalizes phone numbers to E.164, and
5
+ produces CanonicalSingleNumberReach objects.
6
+
7
+ Timer controls (answerTooSoon, answerTooLate) have no Webex equivalent
8
+ and produce SNR_LOSSY decisions.
9
+
10
+ (from tier2-enterprise-expansion.md §4)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import (
20
+ CanonicalSingleNumberReach,
21
+ DecisionType,
22
+ MigrationStatus,
23
+ MapperResult,
24
+ )
25
+ from wxcli.migration.store import MigrationStore
26
+ from wxcli.migration.transform.mappers.base import (
27
+ Mapper,
28
+ accept_option,
29
+ extract_provenance,
30
+ manual_option,
31
+ skip_option,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # CUCM default timer values (milliseconds) — non-default triggers SNR_LOSSY
37
+ CUCM_DEFAULT_ANSWER_TOO_SOON = 1500
38
+ CUCM_DEFAULT_ANSWER_TOO_LATE = 19000
39
+
40
+ # Basic E.164 check: starts with + and has 8-15 digits
41
+ _E164_RE = re.compile(r"^\+\d{8,15}$")
42
+
43
+
44
+ def _is_e164(number: str) -> bool:
45
+ """Check if a number looks like E.164 format."""
46
+ return bool(_E164_RE.match(number))
47
+
48
+
49
+ def _safe_int(val: Any, default: int | None = None) -> int | None:
50
+ """Safely coerce a value to int."""
51
+ if val is None:
52
+ return default
53
+ try:
54
+ return int(val)
55
+ except (ValueError, TypeError):
56
+ return default
57
+
58
+
59
+ class SNRMapper(Mapper):
60
+ """Map CUCM Remote Destinations to Webex Single Number Reach.
61
+
62
+ Tier 2 expansion: produces CanonicalSingleNumberReach objects and
63
+ SNR_LOSSY decisions for CUCM-only timer controls.
64
+ """
65
+
66
+ name = "snr_mapper"
67
+ depends_on = ["user_mapper"]
68
+
69
+ def map(self, store: MigrationStore) -> MapperResult:
70
+ result = MapperResult()
71
+
72
+ # Group remote destinations by owner user ID
73
+ user_dests: dict[str, list[dict[str, Any]]] = {}
74
+ rd_count = 0
75
+ for rd in store.get_objects("remote_destination"):
76
+ rd_count += 1
77
+ state = rd.get("pre_migration_state") or {}
78
+ owner = state.get("ownerUserId") or ""
79
+ if not owner:
80
+ continue
81
+ user_dests.setdefault(owner, []).append(rd)
82
+
83
+ logger.info(
84
+ "SNR mapping: %d remote_destination objects, %d unique owners",
85
+ rd_count, len(user_dests),
86
+ )
87
+
88
+ for owner_userid, rd_list in user_dests.items():
89
+ user_cid = f"user:{owner_userid}"
90
+ if not store.get_object(user_cid):
91
+ continue
92
+
93
+ # Build numbers list
94
+ numbers = []
95
+ lossy_timers = []
96
+ invalid_numbers = []
97
+ any_enabled = False
98
+
99
+ for rd in rd_list:
100
+ state = rd.get("pre_migration_state") or {}
101
+ dest = state.get("destination") or ""
102
+ name = state.get("name") or ""
103
+ is_mobile = str(state.get("isMobilePhone") or "").lower() in ("true", "t", "1")
104
+ mobile_connect = str(state.get("enableMobileConnect") or "").lower() in ("true", "t", "1")
105
+
106
+ if mobile_connect:
107
+ any_enabled = True
108
+
109
+ # Timer values
110
+ too_soon = _safe_int(state.get("answerTooSoonTimer"), CUCM_DEFAULT_ANSWER_TOO_SOON)
111
+ too_late = _safe_int(state.get("answerTooLateTimer"), CUCM_DEFAULT_ANSWER_TOO_LATE)
112
+
113
+ # Check for non-default timers
114
+ if too_soon != CUCM_DEFAULT_ANSWER_TOO_SOON or too_late != CUCM_DEFAULT_ANSWER_TOO_LATE:
115
+ lossy_timers.append({
116
+ "destination": dest,
117
+ "answer_too_soon": too_soon,
118
+ "answer_too_late": too_late,
119
+ })
120
+
121
+ # Validate number format
122
+ if dest and not _is_e164(dest):
123
+ if len(dest) >= 10 and dest.isdigit():
124
+ # Could be national format — flag for admin validation
125
+ invalid_numbers.append(dest)
126
+
127
+ label = "Mobile" if is_mobile else name
128
+ numbers.append({
129
+ "phone_number": dest,
130
+ "enabled": mobile_connect,
131
+ "name": label,
132
+ "answer_confirmation": False,
133
+ "cucm_answer_too_soon": too_soon,
134
+ "cucm_answer_too_late": too_late,
135
+ })
136
+
137
+ if not numbers:
138
+ continue
139
+
140
+ # Build canonical SNR object
141
+ prov = extract_provenance(rd_list[0])
142
+ snr = CanonicalSingleNumberReach(
143
+ canonical_id=f"single_number_reach:{owner_userid}",
144
+ provenance=prov,
145
+ status=MigrationStatus.ANALYZED,
146
+ user_canonical_id=user_cid,
147
+ enabled=any_enabled,
148
+ numbers=numbers,
149
+ )
150
+ store.upsert_object(snr)
151
+ result.objects_created += 1
152
+
153
+ # Write cross-ref
154
+ store.add_cross_ref(user_cid, snr.canonical_id, "user_has_snr")
155
+
156
+ # Decision: lossy timers
157
+ if lossy_timers:
158
+ decision = self._create_decision(
159
+ store=store,
160
+ decision_type=DecisionType.SNR_LOSSY,
161
+ severity="LOW",
162
+ summary=(
163
+ f"User '{owner_userid}' has {len(lossy_timers)} Remote Destination(s) "
164
+ f"with custom timer values that Webex SNR does not support"
165
+ ),
166
+ context={
167
+ "user_id": user_cid,
168
+ "lossy_timers": lossy_timers,
169
+ },
170
+ options=[
171
+ accept_option("Accept loss — migrate numbers without timer control"),
172
+ skip_option("Skip SNR migration for this user"),
173
+ manual_option("Admin configures custom behavior in Webex"),
174
+ ],
175
+ affected_objects=[user_cid, snr.canonical_id],
176
+ )
177
+ result.decisions.append(decision)
178
+
179
+ # Decision: invalid numbers
180
+ if invalid_numbers:
181
+ decision = self._create_decision(
182
+ store=store,
183
+ decision_type=DecisionType.MISSING_DATA,
184
+ severity="MEDIUM",
185
+ summary=(
186
+ f"User '{owner_userid}' has {len(invalid_numbers)} Remote Destination(s) "
187
+ f"with non-E.164 numbers"
188
+ ),
189
+ context={
190
+ "user_id": user_cid,
191
+ "invalid_numbers": invalid_numbers,
192
+ },
193
+ options=[
194
+ manual_option("Admin provides valid E.164 numbers"),
195
+ skip_option("Skip these entries"),
196
+ ],
197
+ affected_objects=[user_cid, snr.canonical_id],
198
+ )
199
+ result.decisions.append(decision)
200
+
201
+ return result
@@ -0,0 +1,205 @@
1
+ """SoftkeyMapper: CUCM Softkey Templates → Webex PSK config (9800/8875).
2
+
3
+ Maps CUCM per-call-state softkey assignments to Webex Programmable Softkey
4
+ (PSK) keywords and per-state key lists. Only actionable for 9800/8875
5
+ phones (PhoneOS with PSK support). Classic MPP phones produce report
6
+ entries only (is_psk_target=False).
7
+
8
+ (from tier2-phase2-phone-config-design.md §4.3)
9
+
10
+ Cross-ref reads:
11
+ phone_uses_softkey_template (Phone → SoftkeyTemplate) — usage + model
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any
17
+
18
+ from wxcli.migration.models import (
19
+ CanonicalSoftkeyConfig,
20
+ MapperResult,
21
+ MigrationStatus,
22
+ )
23
+ from wxcli.migration.store import MigrationStore
24
+ from wxcli.migration.transform.mappers.base import (
25
+ Mapper,
26
+ extract_provenance,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # CUCM softkey name → Webex PSK keyword (None = unmappable)
32
+ CUCM_SOFTKEY_TO_PSK: dict[str, str | None] = {
33
+ "Trnsfer": "xfer",
34
+ "Confrn": "conf",
35
+ "Park": "park",
36
+ "Pickup": "pickup",
37
+ "GPickUp": "gpickup",
38
+ "Hold": "hold",
39
+ "Resume": "resume",
40
+ "EndCall": "endcall",
41
+ "NewCall": "newcall",
42
+ "Redial": "redial",
43
+ "DND": "dnd",
44
+ "Barge": "bridgein",
45
+ "Join": "join",
46
+ "LiveRcd": "crdstart",
47
+ "CfwdAll": "cfwd",
48
+ "Unpark": "unpark",
49
+ "HLog": "acd_login",
50
+ "Answer": "answer",
51
+ # Unmappable
52
+ "CallBack": None,
53
+ "iDivert": None,
54
+ "QRT": None,
55
+ "MeetMe": None,
56
+ "Mobility": None,
57
+ "cBarge": None,
58
+ }
59
+
60
+ # CUCM call state → Webex PSK state key list name
61
+ CUCM_STATE_TO_PSK_STATE: dict[str, str | None] = {
62
+ "onHook": "idle",
63
+ "offHook": "offHook",
64
+ "ringIn": "ringing",
65
+ "ringOut": "progressing", # API key is "progressingKeyList", not "processingKeyList"
66
+ "connected": "connected",
67
+ "onHold": "hold",
68
+ "sharedActive": "sharedActive",
69
+ "sharedHeld": "sharedHeld",
70
+ "connectedTransfer": "startTransfer",
71
+ "connectedConference": "startConference",
72
+ "transition": "releasing",
73
+ # Unmappable
74
+ "remoteInUse": None,
75
+ "park": None,
76
+ }
77
+
78
+ # 9800/8875 model number substrings that indicate PSK capability
79
+ from wxcli.migration.phone_models import PHONEOS_MODEL_NUMBERS as _PSK_MODEL_NUMBERS
80
+
81
+
82
+ def _is_psk_capable_model(model: str | None) -> bool:
83
+ """Check if a phone model supports Programmable Softkeys (PSK)."""
84
+ if not model:
85
+ return False
86
+ for num in _PSK_MODEL_NUMBERS:
87
+ if num in model:
88
+ return True
89
+ return False
90
+
91
+
92
+ class SoftkeyMapper(Mapper):
93
+ """Maps CUCM Softkey Templates to Webex PSK config or report flags."""
94
+
95
+ name = "softkey_mapper"
96
+ depends_on = ["device_mapper"]
97
+
98
+ def map(self, store: MigrationStore) -> MapperResult:
99
+ result = MapperResult()
100
+
101
+ for tmpl_data in store.get_objects("softkey_template"):
102
+ tmpl_id = tmpl_data["canonical_id"]
103
+ state = tmpl_data.get("pre_migration_state") or {}
104
+ name = state.get("name", tmpl_id.split(":", 1)[-1])
105
+ call_states = state.get("call_states", {})
106
+
107
+ # Count phones and determine PSK eligibility
108
+ phone_refs = [r["from_id"] for r in store.get_cross_refs(to_id=tmpl_id, relationship="phone_uses_softkey_template")]
109
+ phones_using = len(phone_refs)
110
+
111
+ has_psk_phone = False
112
+ for phone_id in phone_refs:
113
+ phone = store.get_object(phone_id)
114
+ if phone:
115
+ # Model may be at top level of raw phone dict OR in pre_migration_state
116
+ model = phone.get("model")
117
+ if not model:
118
+ phone_state = phone.get("pre_migration_state") or {}
119
+ model = phone_state.get("model") or phone_state.get("cucm_product")
120
+ if _is_psk_capable_model(model):
121
+ has_psk_phone = True
122
+ break
123
+
124
+ # Map softkeys per call state
125
+ psk_slot_counter = 0
126
+ psk_mappings: list[dict[str, Any]] = []
127
+ state_key_lists: dict[str, list[str]] = {}
128
+ unmapped: list[dict[str, Any]] = []
129
+
130
+ for cucm_state, softkeys in call_states.items():
131
+ psk_state = CUCM_STATE_TO_PSK_STATE.get(cucm_state)
132
+ if psk_state is None:
133
+ # Unmappable state — flag all softkeys in it
134
+ for sk in softkeys:
135
+ unmapped.append({"cucm_name": sk, "call_state": cucm_state})
136
+ continue
137
+
138
+ mapped_keywords = []
139
+ for sk_name in softkeys:
140
+ psk_keyword = CUCM_SOFTKEY_TO_PSK.get(sk_name)
141
+ if psk_keyword is None and sk_name not in CUCM_SOFTKEY_TO_PSK:
142
+ # Unknown softkey — treat as unmapped
143
+ psk_keyword = None
144
+ if psk_keyword is None:
145
+ unmapped.append({"cucm_name": sk_name, "call_state": cucm_state})
146
+ else:
147
+ mapped_keywords.append(psk_keyword)
148
+ if has_psk_phone and psk_slot_counter < 16:
149
+ psk_slot_counter += 1
150
+ psk_mappings.append({
151
+ "psk_slot": f"PSK{psk_slot_counter}",
152
+ "keyword": psk_keyword,
153
+ })
154
+
155
+ if mapped_keywords:
156
+ state_key_lists[psk_state] = mapped_keywords
157
+
158
+ # Per-device fan-out: create one CanonicalSoftkeyConfig per PSK-capable phone.
159
+ # These per-device objects (is_psk_target=True) drive the execute handler.
160
+ per_device_count = 0
161
+ if has_psk_phone and psk_mappings:
162
+ for phone_id in phone_refs:
163
+ phone = store.get_object(phone_id)
164
+ if not phone:
165
+ continue
166
+ model = phone.get("model")
167
+ if not model:
168
+ phone_state = phone.get("pre_migration_state") or {}
169
+ model = phone_state.get("model") or phone_state.get("cucm_product")
170
+ if not _is_psk_capable_model(model):
171
+ continue
172
+ phone_state = phone.get("pre_migration_state") or {}
173
+ device_name = phone_state.get("name") or phone_id.replace("phone:", "")
174
+ per_device_config = CanonicalSoftkeyConfig(
175
+ canonical_id=f"softkey_config:device:{device_name}",
176
+ provenance=extract_provenance(tmpl_data),
177
+ status=MigrationStatus.ANALYZED,
178
+ cucm_template_name=name,
179
+ is_psk_target=True,
180
+ device_canonical_id=f"device:{device_name}",
181
+ psk_mappings=psk_mappings,
182
+ state_key_lists=state_key_lists,
183
+ unmapped_softkeys=unmapped,
184
+ phones_using=1,
185
+ )
186
+ store.upsert_object(per_device_config)
187
+ per_device_count += 1
188
+
189
+ # Template-level object: report-only (is_psk_target=False).
190
+ # Retains PSK mapping data for assessment; execution handled by per-device objects.
191
+ config = CanonicalSoftkeyConfig(
192
+ canonical_id=f"softkey_config:{name}",
193
+ provenance=extract_provenance(tmpl_data),
194
+ status=MigrationStatus.ANALYZED,
195
+ cucm_template_name=name,
196
+ is_psk_target=False,
197
+ psk_mappings=psk_mappings if has_psk_phone else [],
198
+ state_key_lists=state_key_lists,
199
+ unmapped_softkeys=unmapped,
200
+ phones_using=phones_using,
201
+ )
202
+ store.upsert_object(config)
203
+ result.objects_created += 1 + per_device_count
204
+
205
+ return result
@@ -0,0 +1,264 @@
1
+ """User mapper: CUCM End Users -> Webex Calling People.
2
+
3
+ Maps CUCM end users to CanonicalUser objects with resolved email, location,
4
+ and primary extension. Email resolution uses mailid with userid as fallback.
5
+ Location resolution follows the cross-ref chain:
6
+ user -> user_has_device -> device -> device_in_pool -> device_pool
7
+ -> device_pool_to_location -> location
8
+
9
+ (from 03b-transform-mappers.md §2, user_mapper)
10
+
11
+ Cross-ref reads:
12
+ user_has_device (User -> Device)
13
+ user_has_primary_dn (User -> DN)
14
+ device_in_pool (Device -> DevicePool)
15
+ device_pool_to_location (DevicePool -> Location)
16
+
17
+ Does NOT produce: DUPLICATE_USER, EXTENSION_CONFLICT (analyzer-owned)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import re
24
+ from typing import Any
25
+
26
+ from wxcli.migration.models import (
27
+ CanonicalUser,
28
+ DecisionType,
29
+ MapperResult,
30
+ MigrationStatus,
31
+ )
32
+ from wxcli.migration.store import MigrationStore
33
+ from wxcli.migration.transform.mappers.base import (
34
+ Mapper,
35
+ accept_option,
36
+ decision_to_store_dict,
37
+ extract_provenance,
38
+ manual_option,
39
+ skip_option,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Simple email validation pattern
45
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
46
+
47
+
48
+ class UserMapper(Mapper):
49
+ """Maps CUCM End Users to Webex Calling People.
50
+
51
+ (from 03b-transform-mappers.md §2)
52
+ """
53
+
54
+ name = "user_mapper"
55
+ depends_on = ["location_mapper"]
56
+
57
+ def __init__(
58
+ self,
59
+ create_method: str = "people_api",
60
+ include_phoneless_users: bool = False,
61
+ use_device_dn_as_extension: bool = True,
62
+ ) -> None:
63
+ """
64
+ Args:
65
+ create_method: "scim" or "people_api" — execution strategy
66
+ (from 03b-transform-mappers.md §2: execution strategy note)
67
+ include_phoneless_users: If True, map users without phones.
68
+ (from 03b-transform-mappers.md §2: edge case — users with no phone)
69
+ use_device_dn_as_extension: If True, fall back to device line 1 DN
70
+ when primaryExtension is not set on the user record.
71
+ """
72
+ self.create_method = create_method
73
+ self.include_phoneless_users = include_phoneless_users
74
+ self.use_device_dn_as_extension = use_device_dn_as_extension
75
+
76
+ def map(self, store: MigrationStore) -> MapperResult:
77
+ """Read normalized CUCM users and produce CanonicalUser objects."""
78
+ result = MapperResult()
79
+
80
+ for user_data in store.get_objects("user"):
81
+ user_id = user_data["canonical_id"]
82
+ state = user_data.get("pre_migration_state") or {}
83
+
84
+ # --- Email resolution ---
85
+ # (from 03b-transform-mappers.md §2: mailid primary, userid fallback)
86
+ emails = user_data.get("emails", [])
87
+ cucm_mailid = user_data.get("cucm_mailid")
88
+ cucm_userid = user_data.get("cucm_userid", "")
89
+
90
+ if not emails:
91
+ if cucm_mailid and _EMAIL_RE.match(cucm_mailid):
92
+ emails = [cucm_mailid.lower()]
93
+ elif cucm_userid and _EMAIL_RE.match(cucm_userid):
94
+ emails = [cucm_userid.lower()]
95
+
96
+ if not emails:
97
+ # No email available
98
+ # (from 03b-transform-mappers.md §2: MISSING_DATA if no mailid and userid not email)
99
+ decision = self._create_decision(
100
+ store=store,
101
+ decision_type=DecisionType.MISSING_DATA,
102
+ severity="HIGH",
103
+ summary=f"User '{cucm_userid}' has no email address (mailid empty, userid not email format)",
104
+ context={
105
+ "user_id": user_id,
106
+ "cucm_userid": cucm_userid,
107
+ "cucm_mailid": cucm_mailid,
108
+ },
109
+ options=[
110
+ manual_option("Admin provides email address"),
111
+ skip_option("User not migrated to Webex Calling"),
112
+ ],
113
+ affected_objects=[user_id],
114
+ )
115
+ store.save_decision(decision_to_store_dict(decision))
116
+ result.decisions.append(decision)
117
+ continue
118
+
119
+ # --- Device and location resolution ---
120
+ # Chain: user -> user_has_device -> device -> device_in_pool -> pool -> device_pool_to_location -> location
121
+ device_refs = store.find_cross_refs(user_id, "user_has_device")
122
+ location_id = None
123
+
124
+ if device_refs:
125
+ # Try each device until we find one with a location chain
126
+ for dev_id in device_refs:
127
+ loc_id = store.resolve_chain(
128
+ dev_id, "device_in_pool", "device_pool_to_location"
129
+ )
130
+ if loc_id:
131
+ location_id = loc_id
132
+ break
133
+
134
+ if not location_id and not self.include_phoneless_users:
135
+ # No device -> no location
136
+ # (from 03b-transform-mappers.md §2: MISSING_DATA if no device/location)
137
+ decision = self._create_decision(
138
+ store=store,
139
+ decision_type=DecisionType.MISSING_DATA,
140
+ severity="MEDIUM",
141
+ summary=f"User '{cucm_userid}' has no associated phone — cannot resolve Webex location",
142
+ context={
143
+ "user_id": user_id,
144
+ "cucm_userid": cucm_userid,
145
+ "reason": "no_device_no_location",
146
+ },
147
+ options=[
148
+ manual_option("Admin assigns user to a location manually"),
149
+ skip_option("User not migrated to Webex Calling"),
150
+ ],
151
+ affected_objects=[user_id],
152
+ )
153
+ store.save_decision(decision_to_store_dict(decision))
154
+ result.decisions.append(decision)
155
+ continue
156
+
157
+ # --- Extension resolution ---
158
+ # (from 03b-transform-mappers.md §2: primary DN, bare extension without routing prefix)
159
+ extension = user_data.get("extension")
160
+ primary_dn_refs = store.find_cross_refs(user_id, "user_has_primary_dn")
161
+ if primary_dn_refs:
162
+ dn_id = primary_dn_refs[0]
163
+ # DN IDs are formatted as "dn:pattern:partition"
164
+ parts = dn_id.split(":", 2)
165
+ if len(parts) >= 2:
166
+ extension = parts[1]
167
+
168
+ # --- Device line 1 DN fallback ---
169
+ # When CUCM has no primaryExtension set on the user record,
170
+ # fall back to the DN on line 1 (ordinal 0) of the user's first device.
171
+ if extension is None and self.use_device_dn_as_extension and device_refs:
172
+ # Collect ALL line-1 DN candidates across devices so we can warn
173
+ # on genuine multi-device conflicts. We still pick the first
174
+ # candidate (legacy selection behaviour) — the warning just
175
+ # surfaces ambiguity for operator review.
176
+ line1_candidates: list[str] = []
177
+ for dev_id in device_refs:
178
+ dn_xrefs = store.get_cross_refs(
179
+ from_id=dev_id, relationship="device_has_dn"
180
+ )
181
+ for xref in dn_xrefs:
182
+ if xref.get("ordinal") == 0:
183
+ dn_target = xref["to_id"]
184
+ # DN IDs are formatted as "dn:pattern:partition"
185
+ dn_parts = dn_target.split(":", 2)
186
+ if len(dn_parts) >= 2:
187
+ line1_candidates.append(dn_parts[1])
188
+ break
189
+ if line1_candidates:
190
+ extension = line1_candidates[0]
191
+ distinct = sorted(set(line1_candidates))
192
+ if len(distinct) > 1:
193
+ logger.warning(
194
+ "Multiple devices report different line-1 DNs for %s: "
195
+ "candidates=%s picked=%s (legacy selection: first device). "
196
+ "Operator should verify the correct primary extension.",
197
+ user_id,
198
+ distinct,
199
+ extension,
200
+ )
201
+
202
+ # --- Build display name ---
203
+ first_name = user_data.get("first_name")
204
+ last_name = user_data.get("last_name")
205
+ display_name = user_data.get("display_name")
206
+ if not display_name:
207
+ name_parts = [p for p in [first_name, last_name] if p]
208
+ display_name = " ".join(name_parts) or None
209
+
210
+ # --- SQL-extracted user detection ---
211
+ # Users extracted via SQL fallback have limited fields.
212
+ # (from Phase 04 corrections: pre_migration_state.extracted_via_sql = true)
213
+ sql_extracted = state.get("extracted_via_sql", False)
214
+ if sql_extracted:
215
+ decision = self._create_decision(
216
+ store=store,
217
+ decision_type=DecisionType.MISSING_DATA,
218
+ severity="LOW",
219
+ summary=f"User '{cucm_userid}' was extracted via SQL fallback — limited field data available",
220
+ context={
221
+ "user_id": user_id,
222
+ "cucm_userid": cucm_userid,
223
+ "reason": "sql_extracted",
224
+ "missing_fields": ["cucm_css", "cucm_voicemail_profile", "selfService", "userLocale"],
225
+ },
226
+ options=[
227
+ manual_option("Admin verifies and supplements user data"),
228
+ accept_option("Proceed with limited data — CSS and voicemail settings will not be migrated"),
229
+ ],
230
+ affected_objects=[user_id],
231
+ )
232
+ store.save_decision(decision_to_store_dict(decision))
233
+ result.decisions.append(decision)
234
+
235
+ # --- Build CanonicalUser ---
236
+ user_obj = CanonicalUser(
237
+ canonical_id=user_id,
238
+ provenance=extract_provenance(user_data),
239
+ status=MigrationStatus.ANALYZED,
240
+ emails=emails,
241
+ first_name=first_name,
242
+ last_name=last_name,
243
+ display_name=display_name,
244
+ location_id=location_id,
245
+ extension=extension,
246
+ department=user_data.get("department"),
247
+ title=user_data.get("title"),
248
+ create_method=self.create_method,
249
+ calling_data=True,
250
+ cucm_manager_user_id=user_data.get("cucm_manager_user_id"),
251
+ cucm_userid=cucm_userid,
252
+ cucm_mailid=cucm_mailid,
253
+ )
254
+
255
+ store.upsert_object(user_obj)
256
+ result.objects_updated += 1
257
+
258
+ # Fix 8: Write user_in_location cross-ref for dependency graph
259
+ if location_id:
260
+ store.add_cross_ref(user_id, location_id, "user_in_location")
261
+
262
+ return result
263
+
264
+