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,367 @@
1
+ """DECT mapper: Canonical DECT networks -> enriched Webex DECT Network objects.
2
+
3
+ Reads CanonicalDECTNetwork objects produced by normalize_dect_group() (post-normalization
4
+ step), resolves locations via device_pool_to_location cross-refs, resolves handset owners
5
+ via device_owned_by_user cross-refs, merges any operator-provided base station inventory,
6
+ auto-selects network model (DBS-110 vs DBS-210), and generates decisions for ambiguous cases.
7
+
8
+ (from docs/superpowers/specs/2026-04-10-dect-migration.md §5c, §7a, §7b)
9
+
10
+ Cross-ref reads:
11
+ device_pool_to_location (DevicePool -> Location) — resolve location per network
12
+ device_owned_by_user (Device -> EndUser) — resolve handset owner
13
+
14
+ Decisions generated:
15
+ DECT_NETWORK_DESIGN — missing location, no base station inventory, or
16
+ multiple zones map to same location
17
+ DECT_HANDSET_ASSIGNMENT — handset has no owner (unowned/shared handset)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any
24
+
25
+ from wxcli.migration.models import (
26
+ CanonicalDECTNetwork,
27
+ DecisionType,
28
+ DeviceCompatibilityTier,
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
+ # Handset count threshold: zones with > 30 handsets require DBS-210.
45
+ # Zones with <= 30 handsets default to DBS-110.
46
+ # (from spec §3c: DBS-110 max 30 line ports; DBS-210 max 1000 line ports)
47
+ _DBS110_MAX_HANDSETS = 30
48
+
49
+
50
+ class DECTMapper(Mapper):
51
+ """Map normalized DECT networks to enriched Webex DECT Network objects.
52
+
53
+ Runs after location_mapper (so device_pool_to_location cross-refs are set),
54
+ device_mapper (so DECT-tier devices exist in the store), and user_mapper
55
+ (so device_owned_by_user cross-refs can be resolved to canonical user IDs).
56
+
57
+ (from docs/superpowers/specs/2026-04-10-dect-migration.md §5c)
58
+ """
59
+
60
+ name = "dect_mapper"
61
+ depends_on = ["location_mapper", "device_mapper", "user_mapper"]
62
+
63
+ def map(self, store: MigrationStore) -> MapperResult:
64
+ """Read CanonicalDECTNetwork objects and enrich them with resolved data."""
65
+ result = MapperResult()
66
+
67
+ networks = store.get_objects("dect_network")
68
+ if not networks:
69
+ return result
70
+
71
+ # Build a map of location_canonical_id -> list of network canonical_ids
72
+ # to detect when multiple zones share the same location.
73
+ # (spec §7a: "multiple device pools with DECT handsets map to the same Webex location")
74
+ location_to_networks: dict[str, list[str]] = {}
75
+
76
+ for network_data in networks:
77
+ network_cid = network_data["canonical_id"]
78
+ pool_name = (network_data.get("pre_migration_state") or {}).get("cucm_device_pool", "")
79
+
80
+ # --- Location resolution ---
81
+ # device_pool_to_location cross-ref is written by LocationMapper.
82
+ # The network's pre_migration_state.cucm_device_pool is the device pool name.
83
+ pool_id = f"device_pool:{pool_name}" if pool_name else None
84
+ location_canonical_id: str | None = None
85
+
86
+ if pool_id:
87
+ loc_refs = store.find_cross_refs(pool_id, "device_pool_to_location")
88
+ location_canonical_id = loc_refs[0] if loc_refs else None
89
+
90
+ if location_canonical_id:
91
+ location_to_networks.setdefault(location_canonical_id, []).append(network_cid)
92
+
93
+ # Store location on the network for the second pass below
94
+ # (we update the network objects after the full scan)
95
+ network_data["_resolved_location"] = location_canonical_id
96
+
97
+ # Second pass: update each network with resolved data and generate decisions.
98
+ for network_data in networks:
99
+ network_cid = network_data["canonical_id"]
100
+ pool_name = (network_data.get("pre_migration_state") or {}).get("cucm_device_pool", "")
101
+ location_canonical_id = network_data.pop("_resolved_location", None)
102
+
103
+ handset_assignments: list[dict[str, Any]] = network_data.get("handset_assignments", [])
104
+ handset_count = len(handset_assignments)
105
+
106
+ # --- Auto-select network model ---
107
+ # DBS-110 supports up to 30 handsets / 1 base station.
108
+ # DBS-210 supports up to 1000 handsets / 254 base stations.
109
+ # (spec §5c rule 5)
110
+ model = "DBS-110" if handset_count <= _DBS110_MAX_HANDSETS else "DBS-210"
111
+
112
+ # --- Resolve handset owners and detect unowned/orphaned handsets ---
113
+ enriched_assignments: list[dict[str, Any]] = []
114
+ for assignment in handset_assignments:
115
+ device_cid = assignment.get("device_canonical_id")
116
+ owner_canonical_id: str | None = None
117
+
118
+ if device_cid:
119
+ # device_owned_by_user cross-ref source is the device canonical_id
120
+ owner_refs = store.find_cross_refs(device_cid, "device_owned_by_user")
121
+ owner_canonical_id = owner_refs[0] if owner_refs else None
122
+
123
+ enriched = dict(assignment)
124
+ enriched["user_canonical_id"] = owner_canonical_id
125
+ enriched_assignments.append(enriched)
126
+
127
+ # Look up device model and extension for decision context
128
+ device_model: str | None = None
129
+ device_extension: str | None = None
130
+ if device_cid:
131
+ device_obj = store.get_object(device_cid)
132
+ if device_obj:
133
+ device_model = device_obj.get("model")
134
+ # Extension from first line appearance
135
+ line_apps = device_obj.get("line_appearances") or []
136
+ if line_apps:
137
+ device_extension = line_apps[0].get("directory_number") or line_apps[0].get("dn")
138
+
139
+ # Determine if a DECT_HANDSET_ASSIGNMENT decision is needed:
140
+ # 1. Handset has no owner (unowned/shared)
141
+ # 2. Owner is transitioning to Webex App (no physical phone in Webex)
142
+ needs_assignment = False
143
+ owner_status = "owned"
144
+
145
+ if owner_canonical_id is None:
146
+ needs_assignment = True
147
+ owner_status = "unowned"
148
+ elif owner_canonical_id:
149
+ # Check if the owner's other devices are all WEBEX_APP tier,
150
+ # meaning the user is transitioning to Webex App only and
151
+ # won't have a DECT handset in Webex.
152
+ # device_owned_by_user: from=device, to=user — reverse lookup
153
+ owner_device_refs = store.get_cross_refs(
154
+ to_id=owner_canonical_id, relationship="device_owned_by_user",
155
+ )
156
+ owner_devices = [ref["from_id"] for ref in owner_device_refs]
157
+ # Filter to non-DECT devices owned by this user
158
+ non_dect_devices = []
159
+ for dev_id in owner_devices:
160
+ dev = store.get_object(dev_id)
161
+ if dev and dev.get("compatibility_tier") != DeviceCompatibilityTier.DECT:
162
+ non_dect_devices.append(dev)
163
+ # If all non-DECT devices are WEBEX_APP, the user is transitioning
164
+ if non_dect_devices and all(
165
+ d.get("compatibility_tier") == DeviceCompatibilityTier.WEBEX_APP
166
+ for d in non_dect_devices
167
+ ):
168
+ needs_assignment = True
169
+ owner_status = "webex_app_transition"
170
+
171
+ if needs_assignment:
172
+ cucm_device_name = assignment.get("cucm_device_name", device_cid or "unknown")
173
+ summary_reason = (
174
+ "has no owner"
175
+ if owner_status == "unowned"
176
+ else "owner is transitioning to Webex App (no physical phone)"
177
+ )
178
+ decision = self._create_decision(
179
+ store=store,
180
+ decision_type=DecisionType.DECT_HANDSET_ASSIGNMENT,
181
+ severity="INFO",
182
+ summary=(
183
+ f"DECT handset '{cucm_device_name}' {summary_reason} — "
184
+ f"needs person or workspace assignment"
185
+ ),
186
+ context={
187
+ "device_canonical_id": device_cid,
188
+ "cucm_device_name": cucm_device_name,
189
+ "model": device_model,
190
+ "extension": device_extension,
191
+ "device_pool": pool_name,
192
+ "owner_status": owner_status,
193
+ "network_canonical_id": network_cid,
194
+ },
195
+ options=[
196
+ accept_option(
197
+ "Create a Webex workspace for this handset and assign as PLACE"
198
+ ),
199
+ manual_option(
200
+ "Assign to a specific person (operator provides person ID/email)"
201
+ ),
202
+ skip_option("Do not migrate this handset"),
203
+ ],
204
+ affected_objects=[network_cid],
205
+ )
206
+ store.save_decision(decision_to_store_dict(decision))
207
+ result.decisions.append(decision)
208
+
209
+ # --- Merge operator-provided base station inventory ---
210
+ # Base station data lives in pre_migration_state.base_stations if provided
211
+ # via --dect-inventory flag on discover. If absent, base_stations stays empty.
212
+ base_stations: list[dict[str, Any]] = (
213
+ (network_data.get("pre_migration_state") or {}).get("base_stations", [])
214
+ or network_data.get("base_stations", [])
215
+ )
216
+ has_inventory = bool(base_stations)
217
+
218
+ # --- Generate DECT_NETWORK_DESIGN decisions for ambiguous cases ---
219
+
220
+ # Case 1: No location resolved
221
+ if location_canonical_id is None:
222
+ decision = self._create_decision(
223
+ store=store,
224
+ decision_type=DecisionType.DECT_NETWORK_DESIGN,
225
+ severity="HIGH",
226
+ summary=(
227
+ f"DECT network for pool '{pool_name}' could not resolve a "
228
+ f"target Webex location — manual assignment required"
229
+ ),
230
+ context={
231
+ "network_canonical_id": network_cid,
232
+ "cucm_device_pool": pool_name,
233
+ "total_handsets": handset_count,
234
+ "base_stations_provided": has_inventory,
235
+ "location_name": None,
236
+ "zone_count": 1,
237
+ "handsets_per_zone": {pool_name: handset_count},
238
+ },
239
+ options=[
240
+ manual_option(
241
+ "Assign this DECT network to a Webex location and re-run the mapper"
242
+ ),
243
+ skip_option("Exclude this DECT network from migration"),
244
+ ],
245
+ affected_objects=[network_cid],
246
+ )
247
+ store.save_decision(decision_to_store_dict(decision))
248
+ result.decisions.append(decision)
249
+
250
+ # Case 2: No base station inventory provided
251
+ if not has_inventory:
252
+ decision = self._create_decision(
253
+ store=store,
254
+ decision_type=DecisionType.DECT_NETWORK_DESIGN,
255
+ severity="MEDIUM",
256
+ summary=(
257
+ f"DECT network for pool '{pool_name}' has no base station inventory — "
258
+ f"provide MAC addresses to enable automated provisioning"
259
+ ),
260
+ context={
261
+ "network_canonical_id": network_cid,
262
+ "cucm_device_pool": pool_name,
263
+ "total_handsets": handset_count,
264
+ "base_stations_provided": False,
265
+ "location_name": location_canonical_id,
266
+ "zone_count": 1,
267
+ "handsets_per_zone": {pool_name: handset_count},
268
+ },
269
+ options=[
270
+ accept_option(
271
+ f"Create one {'DBS-210' if handset_count > _DBS110_MAX_HANDSETS else 'DBS-110'} "
272
+ f"DECT network for this zone — base stations must be added manually in Control Hub"
273
+ ),
274
+ manual_option(
275
+ "Provide base station inventory CSV (--dect-inventory) and re-run"
276
+ ),
277
+ skip_option("Operator will design DECT networks manually in Control Hub"),
278
+ ],
279
+ affected_objects=[network_cid],
280
+ )
281
+ store.save_decision(decision_to_store_dict(decision))
282
+ result.decisions.append(decision)
283
+
284
+ # Case 3: Multiple zones map to the same location (detected via location_to_networks)
285
+ if location_canonical_id and len(location_to_networks.get(location_canonical_id, [])) > 1:
286
+ # Only emit this decision for the first network in the location group to avoid
287
+ # N duplicate decisions. The first network is the canonical_id at index 0.
288
+ zone_group = location_to_networks[location_canonical_id]
289
+ if zone_group[0] == network_cid:
290
+ # Collect per-zone handset counts for all networks in this location
291
+ handsets_per_zone: dict[str, int] = {}
292
+ total_in_loc = 0
293
+ for sibling_cid in zone_group:
294
+ sibling_data = next(
295
+ (n for n in networks if n["canonical_id"] == sibling_cid), {}
296
+ )
297
+ sibling_pool = (sibling_data.get("pre_migration_state") or {}).get(
298
+ "cucm_device_pool", sibling_cid
299
+ )
300
+ sibling_count = len(sibling_data.get("handset_assignments", []))
301
+ handsets_per_zone[sibling_pool] = sibling_count
302
+ total_in_loc += sibling_count
303
+
304
+ decision = self._create_decision(
305
+ store=store,
306
+ decision_type=DecisionType.DECT_NETWORK_DESIGN,
307
+ severity="MEDIUM",
308
+ summary=(
309
+ f"Multiple DECT coverage zones ({len(zone_group)}) map to the same "
310
+ f"Webex location '{location_canonical_id}' — "
311
+ f"decide: one combined network or separate networks per zone"
312
+ ),
313
+ context={
314
+ "network_canonical_id": network_cid,
315
+ "location_name": location_canonical_id,
316
+ "zone_count": len(zone_group),
317
+ "total_handsets": total_in_loc,
318
+ "handsets_per_zone": handsets_per_zone,
319
+ "base_stations_provided": has_inventory,
320
+ },
321
+ options=[
322
+ accept_option(
323
+ "Create one DBS-210 multi-cell network for the location — "
324
+ "all base stations and handsets go into this network"
325
+ ),
326
+ manual_option(
327
+ "Create separate DECT networks per coverage zone — "
328
+ "provide zone-to-base-station mapping"
329
+ ),
330
+ skip_option(
331
+ "Operator will design DECT networks manually in Control Hub "
332
+ "after migration"
333
+ ),
334
+ ],
335
+ affected_objects=zone_group,
336
+ )
337
+ store.save_decision(decision_to_store_dict(decision))
338
+ result.decisions.append(decision)
339
+
340
+ # --- Build and upsert the enriched CanonicalDECTNetwork ---
341
+ provenance = extract_provenance(network_data)
342
+ # Webex DECT display_name max 11 chars (shown on handset screen)
343
+ raw_display = network_data.get("display_name") or network_data.get("network_name") or f"DECT-{pool_name}"
344
+ display_name = raw_display[:11] if raw_display else None
345
+ enriched_network = CanonicalDECTNetwork(
346
+ canonical_id=network_cid,
347
+ provenance=provenance,
348
+ status=MigrationStatus.ANALYZED,
349
+ location_canonical_id=location_canonical_id,
350
+ network_name=network_data.get("network_name") or f"DECT-{pool_name}",
351
+ display_name=display_name,
352
+ model=model,
353
+ access_code=network_data.get("access_code") or "",
354
+ base_stations=base_stations,
355
+ handset_assignments=enriched_assignments,
356
+ pre_migration_state={
357
+ **(network_data.get("pre_migration_state") or {}),
358
+ "handset_count": handset_count,
359
+ "has_base_station_inventory": has_inventory,
360
+ "auto_selected_model": model,
361
+ },
362
+ )
363
+
364
+ store.upsert_object(enriched_network)
365
+ result.objects_created += 1
366
+
367
+ return result
@@ -0,0 +1,289 @@
1
+ """DeviceLayoutMapper: per-device resolved line key + KEM layout.
2
+
3
+ Merges CanonicalLineKeyTemplate structure with per-phone line appearances,
4
+ speed dials, and BLF entries. Resolves DNs to canonical user IDs and
5
+ detects shared lines via CanonicalSharedLine objects.
6
+
7
+ Reads raw phone objects (object_type="phone") which contain the full AXL
8
+ getPhone response in pre_migration_state. Key fields:
9
+ - lines: list of line dicts with {index, label, dirn: {pattern, routePartitionName}}
10
+ - speeddials: {speeddial: [{dirn, label, index}, ...]}
11
+ - busyLampFields: {busyLampField: [{blfDest, label, index}, ...]}
12
+ - ownerUserName: zeep ref dict {"_value_1": "userid", "uuid": "..."}
13
+
14
+ (from tier2-phase2-phone-config-design.md §4.2)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any
20
+
21
+ from wxcli.migration.models import (
22
+ CanonicalDeviceLayout,
23
+ MapperResult,
24
+ MigrationStatus,
25
+ )
26
+ from wxcli.migration.store import MigrationStore
27
+ from wxcli.migration.transform.mappers.base import (
28
+ Mapper,
29
+ extract_provenance,
30
+ )
31
+ from wxcli.migration.transform.mappers.monitoring_mapper import (
32
+ _extract_blf_entries,
33
+ _extract_speed_dials,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def _ref_value(field: Any) -> str | None:
40
+ """Extract display value from a zeep ref dict or plain string."""
41
+ if field is None:
42
+ return None
43
+ if isinstance(field, str):
44
+ return field or None
45
+ if isinstance(field, dict):
46
+ val = field.get("_value_1")
47
+ return val if val else None
48
+ return None
49
+
50
+
51
+ class DeviceLayoutMapper(Mapper):
52
+ """Per-device layout: merges button template with actual phone data."""
53
+
54
+ name = "device_layout_mapper"
55
+ depends_on = ["button_template_mapper", "monitoring_mapper", "line_mapper", "device_mapper"]
56
+
57
+ def map(self, store: MigrationStore) -> MapperResult:
58
+ result = MapperResult()
59
+
60
+ shared_dns = self._build_shared_dn_set(store)
61
+
62
+ for phone_data in store.get_objects("phone"):
63
+ phone_id = phone_data["canonical_id"]
64
+ state = phone_data.get("pre_migration_state") or {}
65
+
66
+ # Skip common-area phones (no ownerUserName)
67
+ owner_raw = _ref_value(state.get("ownerUserName"))
68
+ if not owner_raw and state.get("class") == "Phone":
69
+ continue
70
+
71
+ phone_name = state.get("name", phone_id.split(":", 1)[-1])
72
+ device_id = f"device:{phone_name}"
73
+
74
+ # Resolve owner via device cross-ref (device_owned_by_user links device:, not phone:)
75
+ owner_id = None
76
+ owner_refs = store.find_cross_refs(device_id, "device_owned_by_user")
77
+ if owner_refs:
78
+ owner_id = owner_refs[0]
79
+
80
+ # Resolve button template via phone cross-ref
81
+ tmpl_refs = store.find_cross_refs(phone_id, "phone_uses_button_template")
82
+ tmpl_id = None
83
+ tmpl_line_keys: list[dict] = []
84
+ tmpl_kem_keys: list[dict] = []
85
+ if tmpl_refs:
86
+ lkt_id = tmpl_refs[0].replace("button_template:", "line_key_template:")
87
+ lkt = store.get_object(lkt_id)
88
+ if lkt:
89
+ tmpl_id = lkt_id
90
+ lkt_data = lkt if isinstance(lkt, dict) else lkt.model_dump()
91
+ tmpl_line_keys = lkt_data.get("line_keys", [])
92
+ tmpl_kem_keys = lkt_data.get("kem_keys", [])
93
+
94
+ # Raw phone data: lines are a flat list from the extractor
95
+ raw_lines = state.get("lines", [])
96
+ if isinstance(raw_lines, dict):
97
+ raw_lines = raw_lines.get("line", [])
98
+ if isinstance(raw_lines, dict):
99
+ raw_lines = [raw_lines]
100
+
101
+ # Index line appearances by position
102
+ la_by_index: dict[int, dict] = {}
103
+ for la in raw_lines:
104
+ if not isinstance(la, dict):
105
+ continue
106
+ dirn = la.get("dirn")
107
+ if not dirn or not isinstance(dirn, dict):
108
+ continue
109
+ idx = la.get("index")
110
+ if idx is not None:
111
+ la_by_index[int(idx)] = la
112
+
113
+ # Speed dials and BLF indexed by button position
114
+ speed_dials_raw = _extract_speed_dials(state)
115
+ blf_entries_raw = _extract_blf_entries(state)
116
+
117
+ sd_by_index: dict[int, dict] = {}
118
+ for sd in speed_dials_raw:
119
+ idx = sd.get("index") or sd.get("speedDialIndex")
120
+ if idx is not None:
121
+ sd_by_index[int(idx)] = sd
122
+
123
+ blf_by_index: dict[int, dict] = {}
124
+ for blf in blf_entries_raw:
125
+ idx = blf.get("index")
126
+ if idx is not None:
127
+ blf_by_index[int(idx)] = blf
128
+
129
+ # Build line_members from line appearances
130
+ line_members: list[dict[str, Any]] = []
131
+ for la_idx in sorted(la_by_index.keys()):
132
+ la = la_by_index[la_idx]
133
+ dirn = la.get("dirn", {})
134
+ dn = dirn.get("pattern", "")
135
+ partition = _ref_value(dirn.get("routePartitionName"))
136
+ label = la.get("label")
137
+
138
+ member_id = self._resolve_dn_to_user(store, dn)
139
+
140
+ dn_key = f"{dn}:{partition}" if partition else dn
141
+ line_type = (
142
+ "SHARED_LINE"
143
+ if dn_key in shared_dns or dn in shared_dns
144
+ else "PRIMARY"
145
+ )
146
+
147
+ line_members.append({
148
+ "port": la_idx,
149
+ "member_canonical_id": member_id,
150
+ "line_type": line_type,
151
+ "line_label": label,
152
+ })
153
+
154
+ # Merge template keys with phone data
155
+ resolved_line_keys = self._resolve_keys(
156
+ tmpl_line_keys, la_by_index, sd_by_index, blf_by_index, store,
157
+ )
158
+ resolved_kem_keys = self._resolve_keys(
159
+ tmpl_kem_keys, la_by_index, sd_by_index, blf_by_index, store,
160
+ )
161
+
162
+ # Collect speed dials
163
+ speed_dials = []
164
+ for idx in sorted(sd_by_index.keys()):
165
+ sd = sd_by_index[idx]
166
+ speed_dials.append({
167
+ "index": idx,
168
+ "label": sd.get("label", ""),
169
+ "number": sd.get("dirn") or sd.get("speedDialNumber", ""),
170
+ })
171
+
172
+ # Skip CONVERTIBLE devices — they use activation-code registration
173
+ # and configure themselves post-registration. Layout application for
174
+ # these phones is deferred to a post-registration bulk job.
175
+ _skip_device_obj = store.get_object(device_id)
176
+ if _skip_device_obj and _skip_device_obj.get("compatibility_tier") == "convertible":
177
+ continue
178
+
179
+ # Copy device_id_surface from the associated CanonicalDevice
180
+ device_obj = store.get_object(device_id)
181
+ device_id_surface = device_obj.get("device_id_surface", "telephony") if device_obj else "telephony"
182
+
183
+ layout = CanonicalDeviceLayout(
184
+ canonical_id=f"device_layout:{phone_name}",
185
+ provenance=extract_provenance(phone_data),
186
+ status=MigrationStatus.ANALYZED,
187
+ device_canonical_id=device_id,
188
+ template_canonical_id=tmpl_id,
189
+ owner_canonical_id=owner_id,
190
+ line_members=line_members,
191
+ resolved_line_keys=resolved_line_keys,
192
+ resolved_kem_keys=resolved_kem_keys,
193
+ speed_dials=speed_dials,
194
+ device_id_surface=device_id_surface,
195
+ )
196
+ store.upsert_object(layout)
197
+
198
+ store.add_cross_ref(phone_id, layout.canonical_id, "phone_has_layout")
199
+ if device_obj is not None:
200
+ store.add_cross_ref(device_id, layout.canonical_id, "device_has_layout")
201
+ if layout.template_canonical_id:
202
+ store.add_cross_ref(
203
+ layout.canonical_id,
204
+ layout.template_canonical_id,
205
+ "layout_uses_template",
206
+ )
207
+ result.objects_created += 1
208
+
209
+ return result
210
+
211
+ # ------------------------------------------------------------------
212
+ # Private helpers
213
+ # ------------------------------------------------------------------
214
+
215
+ def _resolve_keys(
216
+ self,
217
+ template_keys: list[dict],
218
+ la_by_index: dict[int, dict],
219
+ sd_by_index: dict[int, dict],
220
+ blf_by_index: dict[int, dict],
221
+ store: MigrationStore,
222
+ ) -> list[dict[str, Any]]:
223
+ """Resolve template key slots with phone-specific values."""
224
+ resolved = []
225
+ for key in template_keys:
226
+ idx = key.get("index", 0)
227
+ key_type = key.get("key_type", "OPEN")
228
+ entry: dict[str, Any] = {"index": idx, "key_type": key_type}
229
+
230
+ if key_type in ("PRIMARY_LINE", "SHARED_LINE", "LINE"):
231
+ la = la_by_index.get(idx)
232
+ if la:
233
+ dirn = la.get("dirn", {})
234
+ dn = dirn.get("pattern", "") if isinstance(dirn, dict) else ""
235
+ entry["label"] = la.get("label")
236
+ entry["target_canonical_id"] = self._resolve_dn_to_user(store, dn)
237
+
238
+ elif key_type == "SPEED_DIAL":
239
+ sd = sd_by_index.get(idx)
240
+ if sd:
241
+ entry["label"] = sd.get("label", "")
242
+ entry["value"] = sd.get("dirn") or sd.get("speedDialNumber", "")
243
+
244
+ elif key_type == "MONITOR":
245
+ blf = blf_by_index.get(idx)
246
+ if blf:
247
+ target_dn = blf.get("blfDest") or ""
248
+ entry["label"] = blf.get("label", target_dn)
249
+ entry["target_canonical_id"] = self._resolve_dn_to_user(
250
+ store, target_dn,
251
+ )
252
+
253
+ elif key_type == "CALL_PARK_EXTENSION":
254
+ entry["label"] = "Call Park"
255
+
256
+ resolved.append(entry)
257
+ return resolved
258
+
259
+ def _resolve_dn_to_user(
260
+ self, store: MigrationStore, dn: str,
261
+ ) -> str | None:
262
+ """Resolve a DN to a user canonical_id via line cross-refs."""
263
+ if not dn:
264
+ return None
265
+ for line_data in store.get_objects("line"):
266
+ line_state = line_data.get("pre_migration_state") or {}
267
+ line_ext = (
268
+ line_data.get("extension")
269
+ or line_state.get("extension")
270
+ or line_state.get("pattern")
271
+ )
272
+ if line_ext == dn:
273
+ line_id = line_data["canonical_id"]
274
+ user_refs = store.find_cross_refs(line_id, "line_assigned_to_user")
275
+ if user_refs:
276
+ return user_refs[0]
277
+ return None
278
+
279
+ def _build_shared_dn_set(self, store: MigrationStore) -> set[str]:
280
+ """Build a set of DN keys that are shared lines."""
281
+ shared: set[str] = set()
282
+ for sl in store.get_objects("shared_line"):
283
+ dn_id = sl.get("dn_canonical_id", "")
284
+ if dn_id:
285
+ parts = dn_id.split(":", 1)
286
+ if len(parts) > 1:
287
+ shared.add(parts[1])
288
+ shared.add(dn_id)
289
+ return shared