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,1136 @@
1
+ """Pass 2: CrossReferenceBuilder — builds indexes and enriches objects using full inventory.
2
+
3
+ After all objects are in SQLite (from pass 1 normalizers), this class sweeps the
4
+ inventory to build the cross_refs table, detect shared lines, classify phone models,
5
+ normalize DNs to E.164, and build the CSS partition graph.
6
+
7
+ (from 02-normalization-architecture.md, Pass 2 specification)
8
+ (from 02-normalization-architecture.md, Complete Cross-Reference Manifest — 27 rows)
9
+
10
+ Note: Relationship `device_pool_to_location` (#note in manifest) is NOT built here —
11
+ it is written by location_mapper during the transform pass.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+ from wxcli.migration.models import (
21
+ CanonicalDevice,
22
+ CanonicalSharedLine,
23
+ DeviceCompatibilityTier,
24
+ MigrationObject,
25
+ MigrationStatus,
26
+ Provenance,
27
+ )
28
+ from wxcli.migration.store import MigrationStore
29
+ from wxcli.migration.transform.e164 import E164Result, normalize_dn
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _now() -> datetime:
35
+ return datetime.now(timezone.utc)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Phone model compatibility table (four-tier classification)
40
+ # (from cucm-wxc-migration.md lines 363-369)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # DECT handset models — wireless handsets that need DECT network provisioning,
44
+ # NOT desk phones. Checked before _NATIVE_MPP_PATTERNS to prevent false classification.
45
+ _DECT_PATTERNS = {
46
+ "Cisco 6823", "Cisco 6825", "Cisco 6825ip",
47
+ "Cisco IP Phone 6823", "Cisco IP Phone 6825", "Cisco IP Phone 6825ip",
48
+ }
49
+
50
+ # Native MPP: phones that ship with or run MPP/PhoneOS firmware natively
51
+ # Phase 12c: expanded from original 68xx-only list after web search verification
52
+ # Sources: help.webex.com/qkwt4j, Cisco 9800 data sheet, 8875 landing page
53
+ _NATIVE_MPP_PATTERNS = {
54
+ # 6800 series — MPP firmware
55
+ "Cisco 6821", "Cisco 6841", "Cisco 6851", "Cisco 6861", "Cisco 6871",
56
+ "Cisco IP Phone 6821", "Cisco IP Phone 6841", "Cisco IP Phone 6851",
57
+ "Cisco IP Phone 6861", "Cisco IP Phone 6871",
58
+ # 8875 — PhoneOS video phone
59
+ "Cisco 8875", "Cisco IP Phone 8875",
60
+ # 9800 series — PhoneOS, native MPP, no firmware conversion needed
61
+ "Cisco 9811", "Cisco 9821", "Cisco 9841", "Cisco 9851", "Cisco 9861", "Cisco 9871",
62
+ "Cisco IP Phone 9811", "Cisco IP Phone 9821",
63
+ "Cisco IP Phone 9841", "Cisco IP Phone 9851",
64
+ "Cisco IP Phone 9861", "Cisco IP Phone 9871",
65
+ }
66
+
67
+ # Firmware Convertible: Enterprise phones eligible for E2M conversion to MPP
68
+ # Source: Cisco E2M converter (upgrade.cisco.com/e2m_converter_wxc),
69
+ # help.webex.com/nl9j09w (Enterprise-to-MPP migration guide)
70
+ # Phase 12c: added 7811, 7832, 8811, 8832, 8841 per Cisco conversion guide
71
+ _CONVERTIBLE_PATTERNS = {
72
+ # 7800 series — all eligible per E2M converter
73
+ "Cisco 7811", "Cisco 7821", "Cisco 7832", "Cisco 7841", "Cisco 7861",
74
+ "Cisco IP Phone 7811", "Cisco IP Phone 7821", "Cisco IP Phone 7832",
75
+ "Cisco IP Phone 7841", "Cisco IP Phone 7861",
76
+ # 8800 series audio
77
+ "Cisco 8811", "Cisco 8832", "Cisco 8841", "Cisco 8851", "Cisco 8861",
78
+ "Cisco IP Phone 8811", "Cisco IP Phone 8832", "Cisco IP Phone 8841",
79
+ "Cisco IP Phone 8851", "Cisco IP Phone 8861",
80
+ # 8800 series video
81
+ "Cisco 8845", "Cisco 8865",
82
+ "Cisco IP Phone 8845", "Cisco IP Phone 8865",
83
+ }
84
+
85
+ # Software phones / soft clients — these transition to Webex App, not incompatible.
86
+ # Users keep their number/extension and use Webex App for calling.
87
+ _WEBEX_APP_KEYWORDS = [
88
+ "jabber", "dual mode", "client services framework",
89
+ "ip communicator", "softphone", "soft phone",
90
+ # Video endpoints that transition to Webex Desk / RoomOS
91
+ "telepresence", "dx70", "dx80", "dx650",
92
+ ]
93
+
94
+ # Infrastructure devices — CUCM-only, not user phones, not migrated.
95
+ # Noted in reports but don't generate decisions or migration operations.
96
+ _INFRASTRUCTURE_KEYWORDS = [
97
+ "cti port", "cti route point", "cti remote device",
98
+ "cer ", "analog phone", "media termination point",
99
+ "conference bridge", "music on hold", "mtp",
100
+ "route point", "gateway",
101
+ ]
102
+
103
+ # Everything else is incompatible
104
+ # (79xx, 99xx, 69xx, 39xx series, third-party phones)
105
+
106
+
107
+ def classify_phone_model(model: str | None) -> DeviceCompatibilityTier:
108
+ """Classify a CUCM phone model into the five-tier compatibility system.
109
+
110
+ (from cucm-wxc-migration.md lines 363-369, Phone Model Compatibility table)
111
+ """
112
+ if not model:
113
+ return DeviceCompatibilityTier.INCOMPATIBLE
114
+
115
+ # Check DECT handsets first (6823/6825/6825ip are wireless, not desk phones)
116
+ if model in _DECT_PATTERNS:
117
+ return DeviceCompatibilityTier.DECT
118
+
119
+ # Check native MPP first
120
+ if model in _NATIVE_MPP_PATTERNS:
121
+ return DeviceCompatibilityTier.NATIVE_MPP
122
+
123
+ # Check convertible
124
+ if model in _CONVERTIBLE_PATTERNS:
125
+ return DeviceCompatibilityTier.CONVERTIBLE
126
+
127
+ model_lower = model.lower()
128
+
129
+ # Check for infrastructure devices (CTI ports, gateways, media resources)
130
+ if any(kw in model_lower for kw in _INFRASTRUCTURE_KEYWORDS):
131
+ return DeviceCompatibilityTier.INFRASTRUCTURE
132
+
133
+ # Check for software phones / soft clients → Webex App transition
134
+ if any(kw in model_lower for kw in _WEBEX_APP_KEYWORDS):
135
+ return DeviceCompatibilityTier.WEBEX_APP
136
+
137
+ # Check for MPP suffix pattern (some models include "MPP" in the name).
138
+ # Exclude known-incompatible models that might have MPP firmware loaded.
139
+ # 7811 removed Phase 12c — it IS convertible per Cisco E2M converter.
140
+ _INCOMPATIBLE_WITH_MPP = {"7911", "7912", "7940", "7941", "7942",
141
+ "7945", "7960", "7961", "7962", "7965", "7970",
142
+ "7971", "7975", "3905", "3911", "3951"}
143
+ if "MPP" in model.upper():
144
+ # Check if the model number indicates an incompatible device
145
+ model_nums = [s for s in model.split() if s.isdigit() or s.replace("-", "").isdigit()]
146
+ if not any(n in _INCOMPATIBLE_WITH_MPP for n in model_nums):
147
+ return DeviceCompatibilityTier.NATIVE_MPP
148
+
149
+ return DeviceCompatibilityTier.INCOMPATIBLE
150
+
151
+
152
+ class CrossReferenceBuilder:
153
+ """Pass 2: builds indexes and enriches objects using full inventory.
154
+
155
+ (from 02-normalization-architecture.md, CrossReferenceBuilder class)
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ store: MigrationStore,
161
+ default_country_code: str = "US",
162
+ site_prefix_rules: list[dict] | None = None,
163
+ ) -> None:
164
+ self.store = store
165
+ self.default_country_code = default_country_code
166
+ self.site_prefix_rules = site_prefix_rules or []
167
+
168
+ def build(self) -> dict[str, int]:
169
+ """Build all cross-references and enrichments.
170
+
171
+ Returns a summary dict with counts per relationship type built.
172
+
173
+ (from 02-normalization-architecture.md: CrossReferenceBuilder.build())
174
+ """
175
+ # Clear existing cross-refs for clean rebuild
176
+ # (from 07-idempotency-resumability.md lines 183-187)
177
+ self.store.clear_cross_refs()
178
+
179
+ # Disable FK checks during cross-ref building. Cross-refs reference
180
+ # virtual IDs (DNs, partitions, CSS names) that may not exist as
181
+ # stored objects — they're graph edges for traversal, not strict
182
+ # FK relationships.
183
+ self.store.conn.execute("PRAGMA foreign_keys=OFF")
184
+
185
+ try:
186
+ counts: dict[str, int] = {}
187
+
188
+ # Order matters: some steps depend on earlier cross-refs.
189
+ # Each method is wrapped individually so one failure doesn't
190
+ # prevent the remaining cross-refs from being built.
191
+ for method in [
192
+ self._build_device_pool_refs,
193
+ self._build_user_refs,
194
+ self._build_device_dn_refs,
195
+ self._build_device_ownership_refs,
196
+ self._build_css_partition_graph,
197
+ self._build_css_assignment_refs,
198
+ self._build_routing_refs,
199
+ self._build_feature_refs,
200
+ self._build_voicemail_refs,
201
+ self._build_voicemail_group_refs,
202
+ self._build_template_refs,
203
+ self._build_remote_destination_refs,
204
+ self._build_intercept_refs,
205
+ self._build_audio_refs,
206
+ self._build_executive_assistant_refs,
207
+ ]:
208
+ try:
209
+ counts.update(method())
210
+ except Exception as exc:
211
+ logger.warning("Cross-reference %s failed: %s", method.__name__, exc)
212
+
213
+ # Enrichment steps (depend on cross-refs above)
214
+ for name, method in [
215
+ ("shared_lines_detected", self._detect_shared_lines),
216
+ ("phones_classified", self._classify_phone_models),
217
+ ("dns_normalized", self._normalize_dns_to_e164),
218
+ ]:
219
+ try:
220
+ counts[name] = method()
221
+ except Exception as exc:
222
+ logger.warning("Cross-reference %s failed: %s", method.__name__, exc)
223
+
224
+ return counts
225
+ finally:
226
+ # Re-enable FK checks even if build() throws
227
+ self.store.conn.execute("PRAGMA foreign_keys=ON")
228
+
229
+ # ------------------------------------------------------------------
230
+ # Cross-ref #1, #2: Device Pool relationships
231
+ # ------------------------------------------------------------------
232
+
233
+ def _build_device_pool_refs(self) -> dict[str, int]:
234
+ """Build device_pool_has_datetime_group (#1) and
235
+ device_pool_at_cucm_location (#2).
236
+
237
+ (from 02-normalization-architecture.md manifest rows 1-2)
238
+ """
239
+ counts = {"device_pool_has_datetime_group": 0, "device_pool_at_cucm_location": 0}
240
+ for dp_data in self.store.get_objects("device_pool"):
241
+ dp_id = dp_data["canonical_id"]
242
+ state = dp_data.get("pre_migration_state") or {}
243
+
244
+ # #1: device_pool → datetime_group
245
+ dtg_name = state.get("cucm_datetime_group")
246
+ if dtg_name:
247
+ dtg_id = f"datetime_group:{dtg_name}"
248
+ if self.store.get_object(dtg_id):
249
+ self.store.add_cross_ref(dp_id, dtg_id, "device_pool_has_datetime_group")
250
+ counts["device_pool_has_datetime_group"] += 1
251
+
252
+ # #2: device_pool → cucm_location
253
+ loc_name = state.get("cucm_location_name")
254
+ if loc_name:
255
+ # CUCM location entities may not exist as separate objects in our store.
256
+ # Store the ref with the name-based ID regardless, for location_mapper.
257
+ loc_id = f"cucm_location:{loc_name}"
258
+ self.store.add_cross_ref(dp_id, loc_id, "device_pool_at_cucm_location")
259
+ counts["device_pool_at_cucm_location"] += 1
260
+
261
+ return counts
262
+
263
+ # ------------------------------------------------------------------
264
+ # Cross-ref #3, #4: User relationships
265
+ # ------------------------------------------------------------------
266
+
267
+ def _build_user_refs(self) -> dict[str, int]:
268
+ """Build user_has_device (#3) and user_has_primary_dn (#4).
269
+
270
+ (from 02-normalization-architecture.md manifest rows 3-4)
271
+ """
272
+ counts = {"user_has_device": 0, "user_has_primary_dn": 0}
273
+ for user_data in self.store.get_objects("user"):
274
+ user_id = user_data["canonical_id"]
275
+ state = user_data.get("pre_migration_state") or {}
276
+
277
+ # #3: user → devices
278
+ for dev_name in state.get("associated_devices", []):
279
+ dev_id = f"device:{dev_name}"
280
+ if self.store.get_object(dev_id):
281
+ self.store.add_cross_ref(user_id, dev_id, "user_has_device")
282
+ counts["user_has_device"] += 1
283
+
284
+ # #4: user → primary DN
285
+ ext = user_data.get("extension")
286
+ partition = state.get("primary_partition")
287
+ if ext and partition:
288
+ dn_id = f"dn:{ext}:{partition}"
289
+ self.store.add_cross_ref(user_id, dn_id, "user_has_primary_dn")
290
+ counts["user_has_primary_dn"] += 1
291
+
292
+ return counts
293
+
294
+ # ------------------------------------------------------------------
295
+ # Cross-ref #5, #6, #20: Device → DN, DN → Partition, Line → CSS
296
+ # ------------------------------------------------------------------
297
+
298
+ def _build_device_dn_refs(self) -> dict[str, int]:
299
+ """Build device_has_dn (#5), dn_in_partition (#6), and line_has_css (#20).
300
+
301
+ Iterates all devices (including common-area/workspace devices stored as
302
+ CanonicalDevice) and creates DN cross-refs from line appearances.
303
+
304
+ (from 02-normalization-architecture.md manifest rows 5, 6, 20)
305
+ """
306
+ counts = {"device_has_dn": 0, "dn_in_partition": 0, "line_has_css": 0}
307
+
308
+ for dev_data in self.store.get_objects("device"):
309
+ dev_id = dev_data["canonical_id"]
310
+ for la in dev_data.get("line_appearances", []):
311
+ dn = la.get("dn")
312
+ partition = la.get("partition") or "<None>"
313
+ if not dn:
314
+ continue
315
+
316
+ dn_id = f"dn:{dn}:{partition}"
317
+ line_index = la.get("line_index", 0)
318
+
319
+ # #5: device → DN (with ordinal = line index)
320
+ self.store.add_cross_ref(dev_id, dn_id, "device_has_dn", ordinal=line_index)
321
+ counts["device_has_dn"] += 1
322
+
323
+ # #6: DN → partition
324
+ if partition != "<None>":
325
+ pt_id = f"partition:{partition}"
326
+ self.store.add_cross_ref(dn_id, pt_id, "dn_in_partition")
327
+ counts["dn_in_partition"] += 1
328
+
329
+ # #20: line → CSS (line-level CSS, higher priority than device CSS)
330
+ line_css = la.get("line_css")
331
+ if line_css:
332
+ css_id = f"css:{line_css}"
333
+ self.store.add_cross_ref(dn_id, css_id, "line_has_css")
334
+ counts["line_has_css"] += 1
335
+
336
+ return counts
337
+
338
+ # ------------------------------------------------------------------
339
+ # Cross-ref #7, #8, #9: Device → Pool, Device → Owner, Common-area
340
+ # ------------------------------------------------------------------
341
+
342
+ def _build_user_lookup(self) -> dict[str, str]:
343
+ """Build a lookup table for matching device descriptions to users.
344
+
345
+ Returns {lowercase_key: canonical_id} for multiple match strategies:
346
+ - userid (e.g., "amckenzie")
347
+ - "firstname lastname" (e.g., "adam mckenzie")
348
+ - "lastname, firstname" (e.g., "mckenzie, adam")
349
+ - "firstname" if unique across all users
350
+
351
+ A user can own multiple phones — this lookup just identifies the user,
352
+ the cross-ref system handles multi-device naturally.
353
+ """
354
+ lookup: dict[str, str] = {}
355
+ first_name_counts: dict[str, int] = {}
356
+ users_by_first: dict[str, str] = {}
357
+
358
+ for user_data in self.store.get_objects("user"):
359
+ cid = user_data["canonical_id"]
360
+ userid = (user_data.get("cucm_userid") or "").lower()
361
+ first = (user_data.get("first_name") or "").lower().strip()
362
+ last = (user_data.get("last_name") or "").lower().strip()
363
+
364
+ if userid:
365
+ lookup[userid] = cid
366
+ if first and last:
367
+ lookup[f"{first} {last}"] = cid
368
+ lookup[f"{last}, {first}"] = cid
369
+ lookup[f"{last} {first}"] = cid
370
+ if first:
371
+ first_name_counts[first] = first_name_counts.get(first, 0) + 1
372
+ users_by_first[first] = cid
373
+
374
+ # Only add first-name-only matches if the name is unique
375
+ for first, count in first_name_counts.items():
376
+ if count == 1:
377
+ lookup[first] = users_by_first[first]
378
+
379
+ return lookup
380
+
381
+ def _match_description_to_user(
382
+ self, description: str, user_lookup: dict[str, str]
383
+ ) -> str | None:
384
+ """Try to match a device description to a user.
385
+
386
+ Tries exact match first, then checks if any user key appears as a
387
+ substring in the description (handles "Adam McKenzie - 8845" style).
388
+ """
389
+ if not description:
390
+ return None
391
+ desc_lower = description.lower().strip()
392
+
393
+ # Exact match
394
+ if desc_lower in user_lookup:
395
+ return user_lookup[desc_lower]
396
+
397
+ # Substring match — check if a user's full name appears in the description
398
+ # Only match on names with 2+ parts to avoid false positives on short strings
399
+ best_match: str | None = None
400
+ best_len = 0
401
+ for key, cid in user_lookup.items():
402
+ if len(key) < 4 or " " not in key:
403
+ continue # Skip short keys and single-word keys for substring matching
404
+ if key in desc_lower and len(key) > best_len:
405
+ best_match = cid
406
+ best_len = len(key)
407
+
408
+ return best_match
409
+
410
+ def _build_device_ownership_refs(self) -> dict[str, int]:
411
+ """Build device_in_pool (#7), device_owned_by_user (#8),
412
+ and common_area_device_in_pool (#9).
413
+
414
+ For devices without an explicit CUCM owner, attempts to match the
415
+ device description to a known user (handles "Adam McKenzie - 8845"
416
+ style descriptions). A user can own multiple phones.
417
+
418
+ (from 02-normalization-architecture.md manifest rows 7-9)
419
+ """
420
+ counts = {
421
+ "device_in_pool": 0,
422
+ "device_owned_by_user": 0,
423
+ "common_area_device_in_pool": 0,
424
+ "device_owner_inferred": 0,
425
+ }
426
+
427
+ user_lookup = self._build_user_lookup()
428
+
429
+ for dev_data in self.store.get_objects("device"):
430
+ dev_id = dev_data["canonical_id"]
431
+ state = dev_data.get("pre_migration_state") or {}
432
+ dp_name = state.get("cucm_device_pool")
433
+ owner = state.get("cucm_owner_user")
434
+
435
+ # Fallback: try to infer owner from device description
436
+ if not owner:
437
+ description = dev_data.get("display_name", "")
438
+ matched_user = self._match_description_to_user(
439
+ description, user_lookup
440
+ )
441
+ if matched_user:
442
+ owner = matched_user.removeprefix("user:")
443
+ counts["device_owner_inferred"] += 1
444
+ logger.info(
445
+ "Inferred owner for %s from description '%s' → %s",
446
+ dev_id, description, matched_user,
447
+ )
448
+
449
+ if dp_name:
450
+ dp_id = f"device_pool:{dp_name}"
451
+
452
+ if owner:
453
+ # #7: user-owned device → pool
454
+ self.store.add_cross_ref(dev_id, dp_id, "device_in_pool")
455
+ counts["device_in_pool"] += 1
456
+
457
+ # #8: device → owner
458
+ user_id = f"user:{owner}"
459
+ self.store.add_cross_ref(dev_id, user_id, "device_owned_by_user")
460
+ counts["device_owned_by_user"] += 1
461
+
462
+ # Mirror from phone:{name} for mappers that read raw phone objects
463
+ # (CallForwardingMapper, MonitoringMapper, DeviceLayoutMapper)
464
+ device_name = dev_id.removeprefix("device:")
465
+ phone_id = f"phone:{device_name}"
466
+ self.store.add_cross_ref(phone_id, user_id, "device_owned_by_user")
467
+ else:
468
+ # #9: common-area device → pool
469
+ self.store.add_cross_ref(dev_id, dp_id, "common_area_device_in_pool")
470
+ counts["common_area_device_in_pool"] += 1
471
+
472
+ # Also handle workspace objects
473
+ for ws_data in self.store.get_objects("workspace"):
474
+ ws_id = ws_data["canonical_id"]
475
+ state = ws_data.get("pre_migration_state") or {}
476
+ dp_name = state.get("cucm_device_pool")
477
+ if dp_name:
478
+ dp_id = f"device_pool:{dp_name}"
479
+ self.store.add_cross_ref(ws_id, dp_id, "common_area_device_in_pool")
480
+ counts["common_area_device_in_pool"] += 1
481
+
482
+ return counts
483
+
484
+ # ------------------------------------------------------------------
485
+ # Cross-ref #16, #17: CSS → Partition graph
486
+ # ------------------------------------------------------------------
487
+
488
+ def _build_css_partition_graph(self) -> dict[str, int]:
489
+ """Build css_contains_partition (#16) and partition_has_pattern (#17).
490
+
491
+ CSS partition ordering is CRITICAL — ordinal preserves partition priority.
492
+
493
+ (from 02-normalization-architecture.md manifest rows 16-17)
494
+ (from 04-css-decomposition.md: "ordinal values preserving partition priority")
495
+ """
496
+ counts = {"css_contains_partition": 0, "partition_has_pattern": 0}
497
+
498
+ # #16: CSS → partitions (with ordinal)
499
+ for css_data in self.store.get_objects("css"):
500
+ css_id = css_data["canonical_id"]
501
+ state = css_data.get("pre_migration_state") or {}
502
+ for pt in state.get("partitions", []):
503
+ pt_name = pt.get("partition_name")
504
+ ordinal = pt.get("index", 0)
505
+ if pt_name:
506
+ pt_id = f"partition:{pt_name}"
507
+ self.store.add_cross_ref(
508
+ css_id, pt_id, "css_contains_partition", ordinal=ordinal
509
+ )
510
+ counts["css_contains_partition"] += 1
511
+
512
+ # #17: partition → patterns (reverse index built from route patterns and DNs)
513
+ # Route patterns
514
+ for rp_data in self.store.get_objects("route_pattern"):
515
+ rp_id = rp_data["canonical_id"]
516
+ state = rp_data.get("pre_migration_state") or {}
517
+ partition = state.get("partition")
518
+ if partition:
519
+ pt_id = f"partition:{partition}"
520
+ self.store.add_cross_ref(pt_id, rp_id, "partition_has_pattern")
521
+ counts["partition_has_pattern"] += 1
522
+
523
+ # DNs in partitions (already have dn_in_partition refs, build reverse)
524
+ dn_refs = self.store.get_cross_refs(relationship="dn_in_partition")
525
+ for ref in dn_refs:
526
+ # ref: from_id=dn_id, to_id=partition_id
527
+ self.store.add_cross_ref(ref["to_id"], ref["from_id"], "partition_has_pattern")
528
+ counts["partition_has_pattern"] += 1
529
+
530
+ return counts
531
+
532
+ # ------------------------------------------------------------------
533
+ # Cross-ref #18, #19: CSS assignments (user, device)
534
+ # ------------------------------------------------------------------
535
+
536
+ def _build_css_assignment_refs(self) -> dict[str, int]:
537
+ """Build user_has_css (#18) and device_has_css (#19).
538
+
539
+ (from 02-normalization-architecture.md manifest rows 18-19)
540
+ """
541
+ counts = {"user_has_css": 0, "device_has_css": 0}
542
+
543
+ # #18: user → CSS
544
+ for user_data in self.store.get_objects("user"):
545
+ user_id = user_data["canonical_id"]
546
+ state = user_data.get("pre_migration_state") or {}
547
+ css_name = state.get("cucm_css")
548
+ if css_name:
549
+ css_id = f"css:{css_name}"
550
+ self.store.add_cross_ref(user_id, css_id, "user_has_css")
551
+ counts["user_has_css"] += 1
552
+
553
+ # #19: device → CSS
554
+ for dev_data in self.store.get_objects("device"):
555
+ dev_id = dev_data["canonical_id"]
556
+ state = dev_data.get("pre_migration_state") or {}
557
+ css_name = state.get("cucm_css")
558
+ if css_name:
559
+ css_id = f"css:{css_name}"
560
+ self.store.add_cross_ref(dev_id, css_id, "device_has_css")
561
+ counts["device_has_css"] += 1
562
+
563
+ return counts
564
+
565
+ # ------------------------------------------------------------------
566
+ # Cross-ref #10-15: Routing relationships
567
+ # ------------------------------------------------------------------
568
+
569
+ def _build_routing_refs(self) -> dict[str, int]:
570
+ """Build routing cross-refs #10-15.
571
+
572
+ (from 02-normalization-architecture.md manifest rows 10-15)
573
+ """
574
+ counts = {
575
+ "route_pattern_in_partition": 0,
576
+ "route_pattern_uses_gateway": 0,
577
+ "route_pattern_uses_route_list": 0,
578
+ "route_group_has_trunk": 0,
579
+ "trunk_at_location": 0,
580
+ "translation_pattern_in_partition": 0,
581
+ }
582
+
583
+ # #10: route_pattern → partition
584
+ # #11: route_pattern → gateway (if destination is a gateway)
585
+ # #12: route_pattern → route_list (if destination is a route list)
586
+ for rp_data in self.store.get_objects("route_pattern"):
587
+ rp_id = rp_data["canonical_id"]
588
+ state = rp_data.get("pre_migration_state") or {}
589
+ partition = state.get("partition")
590
+ if partition:
591
+ pt_id = f"partition:{partition}"
592
+ self.store.add_cross_ref(rp_id, pt_id, "route_pattern_in_partition")
593
+ counts["route_pattern_in_partition"] += 1
594
+
595
+ # #11/#12: destination data comes from getRoutePattern detail.
596
+ # The normalizer stores target_type and target_name if available.
597
+ target_type = state.get("target_type")
598
+ target_name = state.get("target_name")
599
+ if target_name and target_type:
600
+ if target_type in ("gateway", "trunk"):
601
+ target_id = f"sip_trunk:{target_name}"
602
+ if not self.store.get_object(target_id):
603
+ target_id = f"gateway:{target_name}"
604
+ self.store.add_cross_ref(
605
+ rp_id, target_id, "route_pattern_uses_gateway"
606
+ )
607
+ counts["route_pattern_uses_gateway"] += 1
608
+ elif target_type in ("route_list", "routeList"):
609
+ target_id = f"route_list:{target_name}"
610
+ self.store.add_cross_ref(
611
+ rp_id, target_id, "route_pattern_uses_route_list"
612
+ )
613
+ counts["route_pattern_uses_route_list"] += 1
614
+
615
+ # #13: route_group → trunks
616
+ for rg_data in self.store.get_objects("route_group"):
617
+ rg_id = rg_data["canonical_id"]
618
+ state = rg_data.get("pre_migration_state") or {}
619
+ for trunk in state.get("trunks", []):
620
+ trunk_name = trunk.get("trunk_name")
621
+ if trunk_name:
622
+ # Try sip_trunk first, then gateway
623
+ trunk_id = f"sip_trunk:{trunk_name}"
624
+ if not self.store.get_object(trunk_id):
625
+ trunk_id = f"gateway:{trunk_name}"
626
+ self.store.add_cross_ref(rg_id, trunk_id, "route_group_has_trunk")
627
+ counts["route_group_has_trunk"] += 1
628
+
629
+ # #14: trunk → device_pool → location chain
630
+ for trunk_type in ("sip_trunk", "gateway"):
631
+ for trunk_data in self.store.get_objects(trunk_type):
632
+ trunk_id = trunk_data["canonical_id"]
633
+ state = trunk_data.get("pre_migration_state") or {}
634
+ dp_name = state.get("cucm_device_pool")
635
+ if dp_name:
636
+ dp_id = f"device_pool:{dp_name}"
637
+ self.store.add_cross_ref(trunk_id, dp_id, "trunk_at_location")
638
+ counts["trunk_at_location"] += 1
639
+
640
+ # #15: translation_pattern → partition
641
+ for tp_data in self.store.get_objects("translation_pattern"):
642
+ tp_id = tp_data["canonical_id"]
643
+ state = tp_data.get("pre_migration_state") or {}
644
+ partition = state.get("partition")
645
+ if partition:
646
+ pt_id = f"partition:{partition}"
647
+ self.store.add_cross_ref(tp_id, pt_id, "translation_pattern_in_partition")
648
+ counts["translation_pattern_in_partition"] += 1
649
+
650
+ return counts
651
+
652
+ # ------------------------------------------------------------------
653
+ # Cross-ref #21-25: Feature relationships
654
+ # ------------------------------------------------------------------
655
+
656
+ def _build_feature_refs(self) -> dict[str, int]:
657
+ """Build feature cross-refs #21-25.
658
+
659
+ (from 02-normalization-architecture.md manifest rows 21-25)
660
+ """
661
+ counts = {
662
+ "hunt_pilot_has_hunt_list": 0,
663
+ "hunt_list_has_line_group": 0,
664
+ "line_group_has_members": 0,
665
+ "cti_rp_has_script": 0,
666
+ "schedule_has_time_period": 0,
667
+ }
668
+
669
+ # #21: hunt_pilot → hunt_list
670
+ for hp_data in self.store.get_objects("hunt_pilot"):
671
+ hp_id = hp_data["canonical_id"]
672
+ state = hp_data.get("pre_migration_state") or {}
673
+ hl_name = state.get("hunt_list_name")
674
+ if hl_name:
675
+ hl_id = f"hunt_list:{hl_name}"
676
+ self.store.add_cross_ref(hp_id, hl_id, "hunt_pilot_has_hunt_list")
677
+ counts["hunt_pilot_has_hunt_list"] += 1
678
+
679
+ # #22: hunt_list → line_groups
680
+ for hl_data in self.store.get_objects("hunt_list"):
681
+ hl_id = hl_data["canonical_id"]
682
+ state = hl_data.get("pre_migration_state") or {}
683
+ for lg_name in state.get("line_groups", []):
684
+ lg_id = f"line_group:{lg_name}"
685
+ self.store.add_cross_ref(hl_id, lg_id, "hunt_list_has_line_group")
686
+ counts["hunt_list_has_line_group"] += 1
687
+
688
+ # #23: line_group → members (DN/line references)
689
+ for lg_data in self.store.get_objects("line_group"):
690
+ lg_id = lg_data["canonical_id"]
691
+ state = lg_data.get("pre_migration_state") or {}
692
+ for member in state.get("members", []):
693
+ # Members may be DN references — structure varies by CUCM version.
694
+ # Extract both DN number and partition for accurate canonical_id.
695
+ #
696
+ # Live AXL returns nested dicts:
697
+ # {"lineSelectionOrder": 1,
698
+ # "directoryNumber": {"pattern": "1002",
699
+ # "routePartitionName": {"_value_1": "Internal-PT", "uuid": None},
700
+ # "uuid": "{537A119B-...}"},
701
+ # "uuid": "{50051150-...}"}
702
+ dn_num = None
703
+ partition = "<None>"
704
+ if isinstance(member, dict):
705
+ dn_ref = member.get("directoryNumber") or member.get("pattern")
706
+ if isinstance(dn_ref, dict):
707
+ # Nested AXL dict — extract pattern and partition from inside
708
+ dn_num = dn_ref.get("pattern")
709
+ rp = dn_ref.get("routePartitionName")
710
+ if isinstance(rp, dict):
711
+ partition = rp.get("_value_1") or "<None>"
712
+ elif isinstance(rp, str) and rp:
713
+ partition = rp
714
+ elif isinstance(dn_ref, str):
715
+ # Flat string — directoryNumber is already the pattern
716
+ dn_num = dn_ref
717
+ rp = member.get("routePartitionName")
718
+ if isinstance(rp, dict):
719
+ partition = rp.get("_value_1") or "<None>"
720
+ elif isinstance(rp, str) and rp:
721
+ partition = rp
722
+ elif isinstance(member, str):
723
+ dn_num = member
724
+ if dn_num:
725
+ member_id = f"dn:{dn_num}:{partition}"
726
+ self.store.add_cross_ref(lg_id, member_id, "line_group_has_members")
727
+ counts["line_group_has_members"] += 1
728
+
729
+ # #24: cti_rp → script/application
730
+ for cti_data in self.store.get_objects("cti_rp"):
731
+ cti_id = cti_data["canonical_id"]
732
+ state = cti_data.get("pre_migration_state") or {}
733
+ # Script/app reference is typically external — store if available
734
+ script = state.get("script") or state.get("application")
735
+ if script:
736
+ script_id = f"script:{script}"
737
+ self.store.add_cross_ref(cti_id, script_id, "cti_rp_has_script")
738
+ counts["cti_rp_has_script"] += 1
739
+
740
+ # #25: schedule → time_periods
741
+ for sched_data in self.store.get_objects("time_schedule"):
742
+ sched_id = sched_data["canonical_id"]
743
+ state = sched_data.get("pre_migration_state") or {}
744
+ for tp_name in state.get("time_periods", []):
745
+ tp_id = f"time_period:{tp_name}"
746
+ self.store.add_cross_ref(sched_id, tp_id, "schedule_has_time_period")
747
+ counts["schedule_has_time_period"] += 1
748
+
749
+ return counts
750
+
751
+ # ------------------------------------------------------------------
752
+ # Cross-ref #26, #27: Voicemail relationships
753
+ # ------------------------------------------------------------------
754
+
755
+ def _build_voicemail_refs(self) -> dict[str, int]:
756
+ """Build user_has_voicemail_profile (#26) and voicemail_profile_settings (#27).
757
+
758
+ (from 02-normalization-architecture.md manifest rows 26-27)
759
+ """
760
+ counts = {"user_has_voicemail_profile": 0, "voicemail_profile_settings": 0}
761
+
762
+ # #26: user → voicemail_profile
763
+ for user_data in self.store.get_objects("user"):
764
+ user_id = user_data["canonical_id"]
765
+ state = user_data.get("pre_migration_state") or {}
766
+ vm_profile = state.get("cucm_voicemail_profile")
767
+ if vm_profile:
768
+ vm_id = f"voicemail_profile:{vm_profile}"
769
+ self.store.add_cross_ref(user_id, vm_id, "user_has_voicemail_profile")
770
+ counts["user_has_voicemail_profile"] += 1
771
+
772
+ # #27: voicemail_profile → settings
773
+ # Settings are stored in the voicemail profile object's pre_migration_state.
774
+ # The cross-ref links profile to itself with settings for the voicemail_mapper.
775
+ for vm_data in self.store.get_objects("voicemail_profile"):
776
+ vm_id = vm_data["canonical_id"]
777
+ state = vm_data.get("pre_migration_state") or {}
778
+ if state:
779
+ self.store.add_cross_ref(vm_id, vm_id, "voicemail_profile_settings")
780
+ counts["voicemail_profile_settings"] += 1
781
+
782
+ return counts
783
+
784
+ # ------------------------------------------------------------------
785
+ # Cross-ref: Hunt pilot/list → Voicemail Group
786
+ # (from docs/superpowers/specs/2026-04-10-voicemail-groups.md §Phase 3)
787
+ # ------------------------------------------------------------------
788
+
789
+ def _build_voicemail_group_refs(self) -> dict[str, int]:
790
+ """Build hunt_group_uses_voicemail_group cross-refs.
791
+
792
+ Links hunt pilots whose overflow/forward destinations match a
793
+ voicemail group extension. This allows the mapper and report to
794
+ understand which features depend on shared mailboxes.
795
+
796
+ voicemail_group_in_location is written by VoicemailGroupMapper
797
+ (not here) because location resolution requires mapper logic.
798
+ """
799
+ counts = {"hunt_group_uses_voicemail_group": 0}
800
+
801
+ # Index voicemail groups by extension for O(1) lookup.
802
+ vg_by_ext: dict[str, str] = {}
803
+ for vg in self.store.get_objects("voicemail_group"):
804
+ state = vg.get("pre_migration_state") or {}
805
+ ext = state.get("extension")
806
+ if ext:
807
+ vg_by_ext[str(ext)] = vg["canonical_id"]
808
+
809
+ if not vg_by_ext:
810
+ return counts
811
+
812
+ # Check hunt pilot forward/overflow destinations against VM group extensions.
813
+ for hp in self.store.get_objects("hunt_pilot"):
814
+ hp_id = hp["canonical_id"]
815
+ state = hp.get("pre_migration_state") or {}
816
+
817
+ destinations = [
818
+ state.get("forward_no_answer_destination"),
819
+ state.get("forward_busy_destination"),
820
+ state.get("overflow_destination"),
821
+ ]
822
+ for dest in destinations:
823
+ if dest and str(dest) in vg_by_ext:
824
+ self.store.add_cross_ref(
825
+ hp_id, vg_by_ext[str(dest)],
826
+ "hunt_group_uses_voicemail_group",
827
+ )
828
+ counts["hunt_group_uses_voicemail_group"] += 1
829
+ break # one link per hunt pilot is sufficient
830
+
831
+ return counts
832
+
833
+ # ------------------------------------------------------------------
834
+ # Cross-refs: Phone → Button Template, Phone → Softkey Template
835
+ # (from tier2-phase2-phone-config-design.md §2.4)
836
+ # ------------------------------------------------------------------
837
+
838
+ def _build_template_refs(self) -> dict[str, int]:
839
+ """Build phone_uses_button_template, phone_uses_softkey_template, and
840
+ phone_uses_common_phone_config cross-references.
841
+
842
+ Raw phone objects store AXL zeep dicts in pre_migration_state.
843
+ Template names are in phoneTemplateName/softkeyTemplateName as
844
+ zeep ref dicts: {"_value_1": "Template Name", "uuid": "..."}.
845
+ Common phone config name may be a zeep ref dict or a plain string
846
+ (already normalized by normalize_phone).
847
+ """
848
+ bt_count = 0
849
+ sk_count = 0
850
+ cpc_count = 0
851
+
852
+ for phone in self.store.get_objects("phone"):
853
+ phone_id = phone["canonical_id"]
854
+ state = phone.get("pre_migration_state") or {}
855
+
856
+ # Button template: raw AXL field is phoneTemplateName (zeep ref dict)
857
+ bt_raw = state.get("phoneTemplateName")
858
+ bt_name = self._ref_value(bt_raw)
859
+ if bt_name:
860
+ bt_id = f"button_template:{bt_name}"
861
+ self.store.add_cross_ref(phone_id, bt_id, "phone_uses_button_template")
862
+ bt_count += 1
863
+
864
+ # Softkey template: raw AXL field is softkeyTemplateName (zeep ref dict)
865
+ sk_raw = state.get("softkeyTemplateName")
866
+ sk_name = self._ref_value(sk_raw)
867
+ if sk_name:
868
+ sk_id = f"softkey_template:{sk_name}"
869
+ self.store.add_cross_ref(phone_id, sk_id, "phone_uses_softkey_template")
870
+ sk_count += 1
871
+
872
+ # Common phone config: raw AXL field or normalized field
873
+ cpc_raw = state.get("commonPhoneConfigName")
874
+ cpc_name = self._ref_value(cpc_raw)
875
+ if not cpc_name:
876
+ cpc_name = state.get("cucm_common_phone_config")
877
+ if cpc_name:
878
+ cpc_id = f"info_common_phone_config:{cpc_name}"
879
+ self.store.add_cross_ref(phone_id, cpc_id, "phone_uses_common_phone_config")
880
+ cpc_count += 1
881
+
882
+ return {
883
+ "phone_uses_button_template": bt_count,
884
+ "phone_uses_softkey_template": sk_count,
885
+ "phone_uses_common_phone_config": cpc_count,
886
+ }
887
+
888
+ def _build_executive_assistant_refs(self) -> dict[str, int]:
889
+ """Build executive/assistant relationship cross-refs.
890
+
891
+ (from executive-assistant-migration spec §4b)
892
+ """
893
+ counts = {
894
+ "executive_has_assistant": 0,
895
+ "assistant_serves_executive": 0,
896
+ "user_is_executive": 0,
897
+ "user_is_assistant": 0,
898
+ }
899
+ for pair_data in self.store.get_objects("exec_asst_pair"):
900
+ state = pair_data.get("pre_migration_state") or {}
901
+ exec_userid = state.get("executive_userid")
902
+ asst_userid = state.get("assistant_userid")
903
+ if not exec_userid or not asst_userid:
904
+ continue
905
+ self.store.add_cross_ref(
906
+ f"user:{exec_userid}", f"user:{asst_userid}", "executive_has_assistant"
907
+ )
908
+ counts["executive_has_assistant"] += 1
909
+ self.store.add_cross_ref(
910
+ f"user:{asst_userid}", f"user:{exec_userid}", "assistant_serves_executive"
911
+ )
912
+ counts["assistant_serves_executive"] += 1
913
+
914
+ for setting_data in self.store.get_objects("exec_setting"):
915
+ state = setting_data.get("pre_migration_state") or {}
916
+ userid = state.get("userid")
917
+ role = state.get("role")
918
+ if not userid:
919
+ continue
920
+ setting_id = setting_data["canonical_id"]
921
+ user_id = f"user:{userid}"
922
+ if role == "EXECUTIVE":
923
+ self.store.add_cross_ref(user_id, setting_id, "user_is_executive")
924
+ counts["user_is_executive"] += 1
925
+ elif role == "EXECUTIVE_ASSISTANT":
926
+ self.store.add_cross_ref(user_id, setting_id, "user_is_assistant")
927
+ counts["user_is_assistant"] += 1
928
+ return counts
929
+
930
+ def _build_remote_destination_refs(self) -> dict[str, int]:
931
+ """Build user → remote_destination cross-refs for SNR.
932
+
933
+ (from tier2-enterprise-expansion.md §4: user_has_snr)
934
+ """
935
+ count = 0
936
+ for rd in self.store.get_objects("remote_destination"):
937
+ state = rd.get("pre_migration_state") or {}
938
+ owner_userid = state.get("ownerUserId") or ""
939
+ if not owner_userid:
940
+ continue
941
+ user_cid = f"user:{owner_userid}"
942
+ if self.store.get_object(user_cid):
943
+ self.store.add_cross_ref(user_cid, rd["canonical_id"], "user_has_remote_destination")
944
+ count += 1
945
+ return {"user_has_remote_destination": count}
946
+
947
+ def _build_intercept_refs(self) -> dict[str, int]:
948
+ """Build user -> intercept_candidate cross-refs."""
949
+ count = 0
950
+ for ic in self.store.get_objects("intercept_candidate"):
951
+ state = ic.get("pre_migration_state") or {}
952
+ userid = state.get("userid") or ""
953
+ if not userid:
954
+ continue
955
+ user_cid = f"user:{userid}"
956
+ if self.store.get_object(user_cid):
957
+ self.store.add_cross_ref(user_cid, ic["canonical_id"], "user_has_intercept_signal")
958
+ count += 1
959
+ return {"user_has_intercept_signal": count}
960
+
961
+ # ------------------------------------------------------------------
962
+ # Cross-ref #33: Feature → MoH audio source
963
+ # ------------------------------------------------------------------
964
+
965
+ def _build_audio_refs(self) -> dict[str, int]:
966
+ """Build feature → MoH source cross-refs.
967
+
968
+ Hunt pilots with networkHoldMohAudioSourceID reference a CUCM MoH
969
+ audio source by numeric ID. Resolve to the canonical_id of the
970
+ music_on_hold object so the report can show which features use
971
+ which custom MoH sources.
972
+ """
973
+ counts = {"feature_uses_moh_source": 0}
974
+
975
+ # Build lookup: cucm_source_id → canonical_id
976
+ moh_lookup: dict[str, str] = {}
977
+ for moh in self.store.get_objects("music_on_hold"):
978
+ source_id = moh.get("cucm_source_id")
979
+ if source_id:
980
+ moh_lookup[str(source_id)] = moh.get("canonical_id", "")
981
+
982
+ if not moh_lookup:
983
+ return counts
984
+
985
+ # Hunt pilots → MoH source
986
+ for hp_data in self.store.get_objects("hunt_pilot"):
987
+ hp_id = hp_data["canonical_id"]
988
+ state = hp_data.get("pre_migration_state") or {}
989
+ queue_calls = state.get("queueCalls") or {}
990
+ moh_source_id = queue_calls.get("networkHoldMohAudioSourceID")
991
+ if moh_source_id and str(moh_source_id) in moh_lookup:
992
+ moh_canonical = moh_lookup[str(moh_source_id)]
993
+ self.store.add_cross_ref(hp_id, moh_canonical, "feature_uses_moh_source")
994
+ counts["feature_uses_moh_source"] += 1
995
+
996
+ return counts
997
+
998
+ @staticmethod
999
+ def _ref_value(field: Any) -> str | None:
1000
+ """Extract display value from a zeep reference field or plain string."""
1001
+ if field is None:
1002
+ return None
1003
+ if isinstance(field, str):
1004
+ return field or None
1005
+ if isinstance(field, dict):
1006
+ val = field.get("_value_1")
1007
+ return val if val else None
1008
+ return None
1009
+
1010
+ # ------------------------------------------------------------------
1011
+ # Enrichment: Shared line detection
1012
+ # ------------------------------------------------------------------
1013
+
1014
+ def _detect_shared_lines(self) -> int:
1015
+ """Detect DNs that appear on multiple devices, create CanonicalSharedLine objects.
1016
+
1017
+ (from 02-normalization-architecture.md, _detect_shared_lines)
1018
+ (from 02b-cucm-extraction.md §2.7, shared line detection)
1019
+ """
1020
+ rows = self.store.conn.execute("""
1021
+ SELECT cr.to_id as dn_id, COUNT(DISTINCT cr.from_id) as device_count
1022
+ FROM cross_refs cr
1023
+ WHERE cr.relationship = 'device_has_dn'
1024
+ GROUP BY cr.to_id
1025
+ HAVING COUNT(DISTINCT cr.from_id) > 1
1026
+ """).fetchall()
1027
+
1028
+ count = 0
1029
+ for row in rows:
1030
+ dn_id = row["dn_id"]
1031
+ device_count = row["device_count"]
1032
+
1033
+ # Find all devices with this DN
1034
+ device_refs = self.store.get_cross_refs(
1035
+ to_id=dn_id, relationship="device_has_dn"
1036
+ )
1037
+
1038
+ # Build device list with owner info
1039
+ device_canonical_ids = []
1040
+ owner_canonical_ids = []
1041
+ for ref in device_refs:
1042
+ dev_id = ref["from_id"]
1043
+ device_canonical_ids.append(dev_id)
1044
+
1045
+ # Find owner via device_owned_by_user
1046
+ owner_refs = self.store.find_cross_refs(dev_id, "device_owned_by_user")
1047
+ for owner_id in owner_refs:
1048
+ if owner_id not in owner_canonical_ids:
1049
+ owner_canonical_ids.append(owner_id)
1050
+
1051
+ # Extract DN pattern from dn_id (format: "dn:pattern:partition")
1052
+ parts = dn_id.split(":", 2)
1053
+ dn_pattern = parts[1] if len(parts) > 1 else dn_id
1054
+
1055
+ shared_line = CanonicalSharedLine(
1056
+ canonical_id=f"shared_line:{dn_id.replace('dn:', '')}",
1057
+ provenance=Provenance(
1058
+ source_system="cucm",
1059
+ source_id=dn_id,
1060
+ source_name=dn_pattern,
1061
+ extracted_at=_now(),
1062
+ ),
1063
+ status=MigrationStatus.NORMALIZED,
1064
+ dn_canonical_id=dn_id,
1065
+ owner_canonical_ids=owner_canonical_ids,
1066
+ device_canonical_ids=device_canonical_ids,
1067
+ )
1068
+ self.store.upsert_object(shared_line)
1069
+ count += 1
1070
+ logger.info(
1071
+ "Shared line detected: %s on %d devices", dn_id, device_count
1072
+ )
1073
+
1074
+ return count
1075
+
1076
+ # ------------------------------------------------------------------
1077
+ # Enrichment: Phone model classification
1078
+ # ------------------------------------------------------------------
1079
+
1080
+ def _classify_phone_models(self) -> int:
1081
+ """Classify all devices by model compatibility tier.
1082
+
1083
+ (from 02-normalization-architecture.md: "apply the four-tier firmware
1084
+ compatibility table")
1085
+ (from cucm-wxc-migration.md lines 363-369)
1086
+ """
1087
+ count = 0
1088
+ for dev_obj in self.store.query_by_type("device"):
1089
+ if not isinstance(dev_obj, CanonicalDevice):
1090
+ continue
1091
+ tier = classify_phone_model(dev_obj.model)
1092
+ if dev_obj.compatibility_tier != tier:
1093
+ dev_obj.compatibility_tier = tier
1094
+ self.store.upsert_object(dev_obj)
1095
+ count += 1
1096
+
1097
+ return count
1098
+
1099
+ # ------------------------------------------------------------------
1100
+ # Enrichment: E.164 DN normalization
1101
+ # ------------------------------------------------------------------
1102
+
1103
+ def _normalize_dns_to_e164(self) -> int:
1104
+ """Normalize all DNs to E.164 using the e164 module.
1105
+
1106
+ For each device's line appearances, apply E.164 normalization using
1107
+ the device's resolved location (and therefore country code from config).
1108
+
1109
+ (from 02-normalization-architecture.md: "apply E.164 normalization")
1110
+ """
1111
+ count = 0
1112
+ for dev_obj in self.store.query_by_type("device"):
1113
+ if not isinstance(dev_obj, CanonicalDevice):
1114
+ continue
1115
+
1116
+ updated = False
1117
+ for la in dev_obj.line_appearances:
1118
+ dn = la.get("dn")
1119
+ if not dn:
1120
+ continue
1121
+
1122
+ result = normalize_dn(
1123
+ dn, self.default_country_code, self.site_prefix_rules
1124
+ )
1125
+ la["e164_result"] = {
1126
+ "e164": result.e164,
1127
+ "extension": result.extension,
1128
+ "classification": result.classification,
1129
+ }
1130
+ updated = True
1131
+
1132
+ if updated:
1133
+ self.store.upsert_object(dev_obj)
1134
+ count += 1
1135
+
1136
+ return count