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,2008 @@
1
+ """Pass 1 normalizers: stateless pure functions (one CUCM dict → one canonical model).
2
+
3
+ Each normalizer takes a raw CUCM dict (as returned by zeep from AXL) and returns
4
+ either a canonical Pydantic model or a MigrationObject with cleaned fields.
5
+ Foreign keys stay as CUCM name strings — pass 2 (CrossReferenceBuilder) resolves them.
6
+
7
+ Properties:
8
+ - Testable in isolation (no database, no index, no other objects)
9
+ - Order-independent (process in any sequence)
10
+ - Parallel-safe (no shared state)
11
+
12
+ (from 02-normalization-architecture.md, Pass 1 specification)
13
+ (from 02b-cucm-extraction.md, per-extractor zeep→canonical mapping tables)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime, timezone
19
+ from typing import Any
20
+
21
+ from wxcli.migration.models import (
22
+ CanonicalDECTNetwork,
23
+ CanonicalDevice,
24
+ CanonicalTranslationPattern,
25
+ CanonicalUser,
26
+ CanonicalVoicemailProfile,
27
+ CanonicalWorkspace,
28
+ DeviceCompatibilityTier,
29
+ MigrationObject,
30
+ MigrationStatus,
31
+ Provenance,
32
+ )
33
+
34
+
35
+ def _now() -> datetime:
36
+ return datetime.now(timezone.utc)
37
+
38
+
39
+ def _to_list(field: dict | list | None, key: str) -> list:
40
+ """Normalize a list field to a flat Python list.
41
+
42
+ Handles both extractor format (already flat list from to_list() helper)
43
+ and raw zeep format (nested dict with key wrapper).
44
+ (Reconciled with Phase 03 extractors which pre-normalize via to_list())
45
+ """
46
+ if field is None:
47
+ return []
48
+ if isinstance(field, list):
49
+ # Extractor format: already a flat list
50
+ return field
51
+ if isinstance(field, dict):
52
+ # Raw zeep format: {'member': [...]} or {'member': single_dict}
53
+ inner = field.get(key, [])
54
+ if isinstance(inner, list):
55
+ return inner
56
+ if isinstance(inner, dict):
57
+ return [inner]
58
+ if inner is None:
59
+ return []
60
+ return [inner]
61
+ return []
62
+
63
+
64
+ def _extract_member_uuids(members: list) -> list[str]:
65
+ """Extract UUID strings from a list of CUCM member dicts.
66
+
67
+ Pickup group members come from AXL as dicts like:
68
+ {'priority': 1, 'pickupGroupLineMember': {'_value_1': 'name', 'uuid': '{UUID}'}}
69
+ This helper walks each member dict to find a UUID in any nested reference
70
+ field and returns a flat list of UUID strings. Already-string members are
71
+ passed through unchanged.
72
+ (Verified via live CUCM — pickup group member dicts contain nested refs)
73
+ """
74
+ result: list[str] = []
75
+ for member in members:
76
+ if isinstance(member, str):
77
+ result.append(member)
78
+ continue
79
+ if isinstance(member, dict):
80
+ uuid_val = _find_uuid_in_member(member)
81
+ if uuid_val:
82
+ result.append(uuid_val)
83
+ return result
84
+
85
+
86
+ def _find_uuid_in_member(member: dict) -> str | None:
87
+ """Find the UUID in a CUCM member dict by scanning nested reference fields.
88
+
89
+ Looks for the zeep reference pattern {'_value_1': str, 'uuid': str} inside
90
+ member dicts. If a top-level 'uuid' key exists on the member itself, that
91
+ is used as a last resort.
92
+ """
93
+ # First: scan values for nested zeep reference dicts containing 'uuid'
94
+ for key, val in member.items():
95
+ if key == "priority":
96
+ continue
97
+ if isinstance(val, dict) and "uuid" in val:
98
+ uuid = val["uuid"]
99
+ if uuid is not None:
100
+ return str(uuid)
101
+ if isinstance(val, str) and (val.startswith("{") or len(val) == 36):
102
+ # Bare UUID string as a direct value
103
+ return val
104
+ # Fallback: top-level uuid on the member dict itself
105
+ if "uuid" in member:
106
+ uuid = member["uuid"]
107
+ if uuid is not None:
108
+ return str(uuid)
109
+ return None
110
+
111
+
112
+ def _extract_ref(field: dict | str | None) -> str | None:
113
+ """Extract _value_1 from a zeep reference field.
114
+
115
+ Zeep AXL references use the pattern {'_value_1': str, 'uuid': str}.
116
+ Empty references come back as {'_value_1': None, 'uuid': None}, NOT plain None.
117
+ (Verified via live CUCM Phase 03)
118
+ """
119
+ if field is None:
120
+ return None
121
+ if isinstance(field, dict):
122
+ val = field.get("_value_1")
123
+ return val if val is not None else None
124
+ return str(field)
125
+
126
+
127
+ def _make_provenance(
128
+ raw: dict, cluster: str = "default", source_name: str | None = None
129
+ ) -> Provenance:
130
+ """Build a Provenance from a raw CUCM dict.
131
+
132
+ (from cucm-wxc-migration.md lines 125-131)
133
+ """
134
+ return Provenance(
135
+ source_system="cucm",
136
+ source_id=raw.get("pkid", ""),
137
+ source_name=source_name or raw.get("name", raw.get("pattern", raw.get("userid", raw.get("dirn", raw.get("domainName", ""))))),
138
+ cluster=cluster,
139
+ extracted_at=_now(),
140
+ )
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # §2.1 — Device Pool normalizer
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def normalize_device_pool(raw: dict, cluster: str = "default") -> MigrationObject:
148
+ """Normalize a CUCM Device Pool dict into a MigrationObject.
149
+
150
+ Device pools are intermediate objects — they feed location_mapper in Phase 05,
151
+ not a direct canonical type. Stored with object_type='device_pool'.
152
+
153
+ (from 02b-cucm-extraction.md §2.1, zeep→canonical mapping table)
154
+ """
155
+ name = raw.get("name", "")
156
+ return MigrationObject(
157
+ canonical_id=f"device_pool:{name}",
158
+ provenance=_make_provenance(raw, cluster),
159
+ status=MigrationStatus.NORMALIZED,
160
+ pre_migration_state={
161
+ "device_pool_name": name,
162
+ "cucm_datetime_group": _extract_ref(raw.get("dateTimeSettingName")),
163
+ "cucm_location_name": _extract_ref(raw.get("locationName")),
164
+ "cucm_cm_group": _extract_ref(raw.get("callManagerGroupName")),
165
+ "cucm_srst": _extract_ref(raw.get("srstName")),
166
+ "cucm_region": _extract_ref(raw.get("regionName")),
167
+ "cucm_media_resource_list": _extract_ref(
168
+ raw.get("mediaResourceListName")
169
+ ),
170
+ },
171
+ )
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # §2.1 — DateTime Group normalizer
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def normalize_datetime_group(raw: dict, cluster: str = "default") -> MigrationObject:
179
+ """Normalize a CUCM DateTime Group dict into a MigrationObject.
180
+
181
+ DateTime groups are intermediate objects — they provide timezone data
182
+ for location_mapper. Stored with object_type='datetime_group'.
183
+
184
+ (from 02b-cucm-extraction.md §2.1, DateTime Group mapping table)
185
+ """
186
+ name = raw.get("name", "")
187
+ return MigrationObject(
188
+ canonical_id=f"datetime_group:{name}",
189
+ provenance=_make_provenance(raw, cluster),
190
+ status=MigrationStatus.NORMALIZED,
191
+ pre_migration_state={
192
+ "datetime_group_name": name,
193
+ "timezone": raw.get("timeZone"),
194
+ },
195
+ )
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # §2.2 — End User normalizer
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def normalize_user(raw: dict, cluster: str = "default") -> CanonicalUser:
203
+ """Normalize a CUCM End User dict into a CanonicalUser.
204
+
205
+ Handles two extraction formats:
206
+ - AXL/zeep: associatedDevices is {'device': [str, ...]}, primaryExtension
207
+ has UUID wrappers, callingSearchSpaceName/voiceMailProfile are references
208
+ - SQL (_extracted_via="sql"): associatedDevices is already a flat list,
209
+ primaryExtension is {pattern, routePartitionName} without UUID wrappers,
210
+ callingSearchSpaceName/voiceMailProfile/selfService/userLocale are None
211
+
212
+ (from 02b-cucm-extraction.md §2.2, zeep→canonical mapping table)
213
+ (from 03b-transform-mappers.md §2, user_mapper field table)
214
+ (Verified via live CUCM Phase 03)
215
+ """
216
+ userid = raw.get("userid", "")
217
+ mailid = raw.get("mailid")
218
+ extracted_via_sql = raw.get("_extracted_via") == "sql"
219
+
220
+ # Email: direct copy, lowercase (from 03b §2: "validated as email format")
221
+ emails: list[str] = []
222
+ if mailid:
223
+ emails = [mailid.lower()]
224
+
225
+ # associatedDevices: AXL format {'device': [str, ...]} or SQL flat list
226
+ # (Verified via live CUCM: SQL extraction returns flat list directly)
227
+ associated_devices: list[str] = []
228
+ assoc = raw.get("associatedDevices")
229
+ if assoc is not None:
230
+ if isinstance(assoc, dict):
231
+ devs = assoc.get("device", [])
232
+ if isinstance(devs, str):
233
+ associated_devices = [devs]
234
+ elif isinstance(devs, list):
235
+ associated_devices = devs
236
+ elif isinstance(assoc, list):
237
+ # SQL-extracted: already a flat list of device names
238
+ associated_devices = assoc
239
+
240
+ # primaryExtension: AXL has UUID wrappers on routePartitionName,
241
+ # SQL-extracted has plain {pattern, routePartitionName} without wrappers
242
+ primary_ext = raw.get("primaryExtension")
243
+ extension = None
244
+ primary_partition = None
245
+ if isinstance(primary_ext, dict):
246
+ extension = primary_ext.get("pattern")
247
+ rp = primary_ext.get("routePartitionName")
248
+ # Handle both AXL reference format and SQL plain string
249
+ if isinstance(rp, dict):
250
+ primary_partition = _extract_ref(rp)
251
+ elif isinstance(rp, str):
252
+ primary_partition = rp
253
+
254
+ # enableCti: may be string or bool depending on extraction method
255
+ enable_cti_raw = raw.get("enableCti")
256
+ if isinstance(enable_cti_raw, bool):
257
+ enable_cti = enable_cti_raw
258
+ else:
259
+ enable_cti = str(enable_cti_raw).lower() == "true" if enable_cti_raw else False
260
+
261
+ return CanonicalUser(
262
+ canonical_id=f"user:{userid}",
263
+ provenance=_make_provenance(raw, cluster),
264
+ status=MigrationStatus.NORMALIZED,
265
+ emails=emails,
266
+ first_name=raw.get("firstName"),
267
+ last_name=raw.get("lastName"),
268
+ display_name=f"{raw.get('firstName', '')} {raw.get('lastName', '')}".strip()
269
+ or None,
270
+ extension=extension,
271
+ department=raw.get("department"),
272
+ title=raw.get("title"),
273
+ cucm_manager_user_id=_extract_ref(raw.get("manager")),
274
+ cucm_userid=userid,
275
+ cucm_mailid=mailid,
276
+ pre_migration_state={
277
+ "telephone_number": raw.get("telephoneNumber"),
278
+ "directory_uri": raw.get("directoryUri"),
279
+ "user_locale": raw.get("userLocale"),
280
+ "voicemail_pilot": raw.get("selfService"),
281
+ "associated_devices": associated_devices,
282
+ "enable_cti": enable_cti,
283
+ "primary_partition": primary_partition,
284
+ "cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
285
+ "cucm_voicemail_profile": _extract_ref(raw.get("voiceMailProfile")),
286
+ # Flag SQL-extracted users so mappers know some fields are unavailable
287
+ # (callingSearchSpaceName, voiceMailProfile, selfService, userLocale)
288
+ "extracted_via_sql": extracted_via_sql,
289
+ },
290
+ )
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # §2.3 — Phone normalizer (→ CanonicalDevice)
295
+ # ---------------------------------------------------------------------------
296
+
297
+ def _normalize_line_appearance(raw_line: dict) -> dict:
298
+ """Normalize a single line appearance from a phone's lines array.
299
+
300
+ (from 02b-cucm-extraction.md §2.3, per-line appearance mapping table)
301
+ """
302
+ dirn = raw_line.get("dirn") or {}
303
+
304
+ # associatedEndusers: extract list of userId values
305
+ # Extractor format (Phase 03): already flat list of strings ['jdoe', 'asmith']
306
+ # Zeep raw format: {'enduser': [{'userId': 'jdoe'}, ...]}
307
+ endusers: list[str] = []
308
+ assoc = raw_line.get("associatedEndusers")
309
+ if isinstance(assoc, list):
310
+ # Extractor format: flat list of user ID strings
311
+ endusers = [str(u) for u in assoc if u]
312
+ elif isinstance(assoc, dict):
313
+ # Zeep raw format
314
+ eu_list = assoc.get("enduser", [])
315
+ if isinstance(eu_list, dict):
316
+ eu_list = [eu_list]
317
+ for eu in eu_list:
318
+ if isinstance(eu, dict) and "userId" in eu:
319
+ endusers.append(eu["userId"])
320
+ elif isinstance(eu, str):
321
+ endusers.append(eu)
322
+
323
+ # Call forwarding fields — 10 fields per line, verified via live CUCM Phase 03.
324
+ # voicemail_mapper reads forwardToVoiceMail flags from these.
325
+ def _extract_cf(field_name: str) -> dict | None:
326
+ cf = dirn.get(field_name)
327
+ if not cf or not isinstance(cf, dict):
328
+ return None
329
+ return {
330
+ "destination": cf.get("destination"),
331
+ "forward_to_voicemail": cf.get("forwardToVoiceMail"),
332
+ "calling_search_space": _extract_ref(cf.get("callingSearchSpaceName")),
333
+ }
334
+
335
+ return {
336
+ "line_index": int(raw_line.get("index", 0)),
337
+ "dn": dirn.get("pattern"),
338
+ "partition": _extract_ref(dirn.get("routePartitionName")),
339
+ "line_label": raw_line.get("label"),
340
+ "display_name": raw_line.get("display"),
341
+ "e164_mask": raw_line.get("e164Mask"),
342
+ "alerting_name": dirn.get("alertingName"),
343
+ "dn_description": dirn.get("description"),
344
+ "shared_line_css": _extract_ref(dirn.get("shareLineAppearanceCssName")),
345
+ "line_css": _extract_ref(dirn.get("callingSearchSpaceName")),
346
+ "associated_endusers": endusers,
347
+ # Call forwarding (verified via live CUCM Phase 03)
348
+ "call_forward_all": _extract_cf("callForwardAll"),
349
+ "call_forward_busy": _extract_cf("callForwardBusy"),
350
+ "call_forward_busy_int": _extract_cf("callForwardBusyInt"),
351
+ "call_forward_no_answer": _extract_cf("callForwardNoAnswer"),
352
+ "call_forward_no_answer_int": _extract_cf("callForwardNoAnswerInt"),
353
+ "call_forward_no_coverage": _extract_cf("callForwardNoCoverage"),
354
+ "call_forward_no_coverage_int": _extract_cf("callForwardNoCoverageInt"),
355
+ "call_forward_on_failure": _extract_cf("callForwardOnFailure"),
356
+ "call_forward_alternate_party": _extract_cf("callForwardAlternateParty"),
357
+ "call_forward_unregistered": _extract_cf("callForwardUnregistered"),
358
+ }
359
+
360
+
361
+ def _normalize_psc(psc: Any) -> dict | None:
362
+ """Normalize productSpecificConfiguration to a flat dict."""
363
+ if psc is None:
364
+ return None
365
+ if isinstance(psc, dict):
366
+ return {k: v for k, v in psc.items() if v is not None} or None
367
+ if isinstance(psc, str):
368
+ return _parse_vendor_config_xml(psc)
369
+ return None
370
+
371
+
372
+ def _parse_vendor_config_xml(xml_str: str) -> dict | None:
373
+ """Parse a vendorConfig or productSpecificConfiguration XML string to flat dict."""
374
+ import xml.etree.ElementTree as ET
375
+ try:
376
+ root = ET.fromstring(xml_str)
377
+ except ET.ParseError:
378
+ return None
379
+ result = {}
380
+ for child in root:
381
+ tag = child.tag
382
+ text = child.text
383
+ if text is not None:
384
+ result[tag] = text.strip()
385
+ return result or None
386
+
387
+
388
+ def normalize_phone(raw: dict, cluster: str = "default") -> CanonicalDevice:
389
+ """Normalize a CUCM Phone dict into a CanonicalDevice.
390
+
391
+ (from 02-normalization-architecture.md, Pass 1 example)
392
+ (from 02b-cucm-extraction.md §2.3, zeep→canonical mapping table)
393
+ """
394
+ device_name = raw.get("name", "")
395
+
396
+ # MAC extraction: 12 hex digits after SEP prefix
397
+ # (from 03b §4: "Extract 12 hex digits after SEP prefix")
398
+ mac = None
399
+ if device_name.startswith("SEP") and len(device_name) >= 15:
400
+ mac = device_name[3:]
401
+
402
+ # Lines: handle empty, None, single dict, or list
403
+ # (from 02b §2.3: "lines may be empty dict or None",
404
+ # "lines.line may be a single dict, not a list")
405
+ # Lines: extractor returns flat list, zeep returns {'line': [...]}
406
+ line_entries = _to_list(raw.get("lines"), "line")
407
+
408
+ # Filter out speed dials: only entries with a dirn element are line appearances
409
+ # (from 02b §2.3: "Speed dial entries have no dirn element")
410
+ line_appearances = [
411
+ _normalize_line_appearance(l) for l in line_entries if l.get("dirn")
412
+ ]
413
+ line_appearances.sort(key=lambda x: x["line_index"])
414
+
415
+ return CanonicalDevice(
416
+ canonical_id=f"device:{device_name}",
417
+ provenance=_make_provenance(raw, cluster),
418
+ status=MigrationStatus.NORMALIZED,
419
+ mac=mac,
420
+ model=raw.get("model"),
421
+ display_name=raw.get("description") or device_name,
422
+ cucm_protocol=raw.get("protocol"),
423
+ cucm_device_name=device_name,
424
+ line_appearances=line_appearances,
425
+ pre_migration_state={
426
+ "cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
427
+ "cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
428
+ "cucm_owner_user": _extract_ref(raw.get("ownerUserName")),
429
+ "cucm_phone_template": _extract_ref(raw.get("phoneTemplateName")),
430
+ "cucm_softkey_template": _extract_ref(raw.get("softkeyTemplateName")),
431
+ "cucm_product": raw.get("product"),
432
+ "cucm_class": raw.get("class"),
433
+ # Device settings fields (for DeviceSettingsMapper)
434
+ "cucm_common_phone_config": _extract_ref(raw.get("commonPhoneConfigName")),
435
+ "product_specific_config": _normalize_psc(raw.get("productSpecificConfiguration")),
436
+ "cucm_user_locale": raw.get("userLocale"),
437
+ "cucm_network_locale": raw.get("networkLocale"),
438
+ "cucm_dnd_option": raw.get("dndOption"),
439
+ "cucm_dnd_status": raw.get("dndStatus"),
440
+ "cucm_extension_mobility": raw.get("enableExtensionMobility"),
441
+ },
442
+ )
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # §2.8 — Common-Area Phone normalizer (→ CanonicalWorkspace)
447
+ # ---------------------------------------------------------------------------
448
+
449
+ def is_common_area_device(raw: dict) -> bool:
450
+ """Classify a phone as common-area based on ownership and type.
451
+
452
+ (from 02b-cucm-extraction.md §2.8: classification filter)
453
+ (from 03b §5: "A CUCM common-area phone with no user owner is a workspace")
454
+ """
455
+ # Use _extract_ref to unwrap zeep empty references:
456
+ # {'_value_1': None, 'uuid': None} should be treated as no owner.
457
+ # (Found by code review — zeep returns empty refs as dicts, not None)
458
+ if _extract_ref(raw.get("ownerUserName")) is not None:
459
+ return False
460
+ device_class = raw.get("class", "")
461
+ return device_class == "Phone"
462
+
463
+
464
+ def normalize_workspace(raw: dict, cluster: str = "default") -> CanonicalWorkspace:
465
+ """Normalize a CUCM common-area phone dict into a CanonicalWorkspace.
466
+
467
+ (from 02b-cucm-extraction.md §2.8)
468
+ (from 03b-transform-mappers.md §5, workspace_mapper field table)
469
+ """
470
+ device_name = raw.get("name", "")
471
+
472
+ # Primary line extension
473
+ # Lines: extractor returns flat list, zeep returns {'line': [...]}
474
+ line_entries = _to_list(raw.get("lines"), "line")
475
+
476
+ extension = None
477
+ for l in line_entries:
478
+ dirn = l.get("dirn")
479
+ if dirn and str(l.get("index", "0")) == "1":
480
+ extension = dirn.get("pattern")
481
+ break
482
+
483
+ return CanonicalWorkspace(
484
+ canonical_id=f"workspace:{device_name}",
485
+ provenance=_make_provenance(raw, cluster),
486
+ status=MigrationStatus.NORMALIZED,
487
+ display_name=raw.get("description") or device_name,
488
+ extension=extension,
489
+ is_common_area=True,
490
+ pre_migration_state={
491
+ "cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
492
+ "cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
493
+ "cucm_model": raw.get("model"),
494
+ "cucm_protocol": raw.get("protocol"),
495
+ "cucm_product": raw.get("product"),
496
+ "cucm_device_name": device_name,
497
+ },
498
+ )
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # §2.4 — CSS normalizer (intermediate for cross-refs)
503
+ # ---------------------------------------------------------------------------
504
+
505
+ def normalize_css(raw: dict, cluster: str = "default") -> MigrationObject:
506
+ """Normalize a CUCM CSS dict into a MigrationObject.
507
+
508
+ CSS objects are intermediate — css_mapper in Phase 05 consumes them.
509
+ The critical data is the ordered partition membership list.
510
+
511
+ (from 02b-cucm-extraction.md §2.4, CSS mapping table)
512
+ (from 04-css-decomposition.md: "ordinal values preserving partition priority")
513
+ """
514
+ name = raw.get("name", "")
515
+
516
+ # Members: ordered list of (partition_name, index) — MUST preserve ordering
517
+ # (from 02b §2.4: "The index field is the partition priority within this CSS
518
+ # and MUST be preserved")
519
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
520
+ member_list = _to_list(raw.get("members"), "member")
521
+
522
+ partitions = []
523
+ for m in member_list:
524
+ pt_name = _extract_ref(m.get("routePartitionName"))
525
+ index = int(m.get("index", 0))
526
+ if pt_name:
527
+ partitions.append({"partition_name": pt_name, "index": index})
528
+ partitions.sort(key=lambda x: x["index"])
529
+
530
+ return MigrationObject(
531
+ canonical_id=f"css:{name}",
532
+ provenance=_make_provenance(raw, cluster),
533
+ status=MigrationStatus.NORMALIZED,
534
+ pre_migration_state={
535
+ "css_name": name,
536
+ "description": raw.get("description"),
537
+ "partitions": partitions,
538
+ },
539
+ )
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # §2.4 — Partition normalizer (intermediate)
544
+ # ---------------------------------------------------------------------------
545
+
546
+ def normalize_partition(raw: dict, cluster: str = "default") -> MigrationObject:
547
+ """Normalize a CUCM Route Partition dict into a MigrationObject.
548
+
549
+ Partitions are intermediate objects used by cross-reference builder.
550
+
551
+ (from 02b-cucm-extraction.md §2.4, Partition returnedTags)
552
+ """
553
+ name = raw.get("name", "")
554
+ return MigrationObject(
555
+ canonical_id=f"partition:{name}",
556
+ provenance=_make_provenance(raw, cluster),
557
+ status=MigrationStatus.NORMALIZED,
558
+ pre_migration_state={
559
+ "partition_name": name,
560
+ "description": raw.get("description"),
561
+ },
562
+ )
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # §2.4 — Route Pattern normalizer (intermediate)
567
+ # ---------------------------------------------------------------------------
568
+
569
+ def normalize_route_pattern(raw: dict, cluster: str = "default") -> MigrationObject:
570
+ """Normalize a CUCM Route Pattern dict into a MigrationObject.
571
+
572
+ Route patterns are intermediate — routing_mapper in Phase 05 consumes them.
573
+
574
+ (from 02b-cucm-extraction.md §2.4, Route Pattern mapping table)
575
+ """
576
+ pattern = raw.get("pattern", "")
577
+ partition = _extract_ref(raw.get("routePartitionName"))
578
+ # Canonical ID includes partition for uniqueness (same pattern in different partitions)
579
+ canonical_id = f"route_pattern:{pattern}:{partition or '<None>'}"
580
+
581
+ # blockEnable: string to action classification
582
+ # (from 04-css-decomposition.md Step 2: "classify patterns as ROUTE or BLOCK")
583
+ block_enable = raw.get("blockEnable")
584
+ action = "BLOCK" if str(block_enable).lower() == "true" else "ROUTE"
585
+
586
+ # Destination/target extraction — getRoutePattern returns the routing target
587
+ # as one of: destination (complex), gatewayName (ref), routeListName (ref)
588
+ gateway_name = _extract_ref(raw.get("gatewayName"))
589
+ route_list_name = _extract_ref(raw.get("routeListName"))
590
+ # Determine target type and name for cross-ref builder and routing mapper
591
+ if gateway_name:
592
+ target_type = "gateway"
593
+ target_name = gateway_name
594
+ elif route_list_name:
595
+ target_type = "route_list"
596
+ target_name = route_list_name
597
+ else:
598
+ target_type = None
599
+ target_name = None
600
+
601
+ return MigrationObject(
602
+ canonical_id=canonical_id,
603
+ provenance=_make_provenance(raw, cluster),
604
+ status=MigrationStatus.NORMALIZED,
605
+ pre_migration_state={
606
+ "pattern": pattern,
607
+ "partition": partition,
608
+ "action": action,
609
+ "description": raw.get("description"),
610
+ "called_transform_mask": raw.get("calledPartyTransformationMask"),
611
+ "calling_transform_mask": raw.get("callingPartyTransformationMask"),
612
+ "prefix_digits_out": raw.get("prefixDigitsOut"),
613
+ "network_location": raw.get("networkLocation"),
614
+ "target_type": target_type,
615
+ "target_name": target_name,
616
+ },
617
+ )
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # §2.4 — Translation Pattern normalizer
622
+ # ---------------------------------------------------------------------------
623
+
624
+ def normalize_translation_pattern(
625
+ raw: dict, cluster: str = "default"
626
+ ) -> CanonicalTranslationPattern:
627
+ """Normalize a CUCM Translation Pattern dict into a CanonicalTranslationPattern.
628
+
629
+ (from 02b-cucm-extraction.md §2.4, Translation Pattern mapping table)
630
+ (from 03b-transform-mappers.md §6, translation pattern field table)
631
+ """
632
+ pattern = raw.get("pattern", "")
633
+ partition = _extract_ref(raw.get("routePartitionName"))
634
+ canonical_id = f"translation_pattern:{pattern}:{partition or '<None>'}"
635
+
636
+ return CanonicalTranslationPattern(
637
+ canonical_id=canonical_id,
638
+ provenance=_make_provenance(raw, cluster),
639
+ status=MigrationStatus.NORMALIZED,
640
+ name=raw.get("description") or pattern,
641
+ matching_pattern=pattern,
642
+ replacement_pattern=raw.get("calledPartyTransformationMask"),
643
+ pre_migration_state={
644
+ "partition": partition,
645
+ },
646
+ )
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # §2.5 — Hunt Pilot normalizer (intermediate)
651
+ # ---------------------------------------------------------------------------
652
+
653
+ def normalize_hunt_pilot(raw: dict, cluster: str = "default") -> MigrationObject:
654
+ """Normalize a CUCM Hunt Pilot dict into a MigrationObject.
655
+
656
+ Hunt pilots are intermediate — feature_mapper resolves the hunt_pilot →
657
+ hunt_list → line_group chain to produce CanonicalHuntGroup or CanonicalCallQueue.
658
+
659
+ (from 02b-cucm-extraction.md §2.5, Hunt Pilot mapping)
660
+ (from 03b-transform-mappers.md §8, feature_mapper)
661
+ """
662
+ pattern = raw.get("pattern", "")
663
+ partition = _extract_ref(raw.get("routePartitionName"))
664
+ canonical_id = f"hunt_pilot:{pattern}:{partition or '<None>'}"
665
+
666
+ # Queue-style detection fields
667
+ # (from 03b §10: queue-style heuristics)
668
+ queue_calls = raw.get("queueCalls")
669
+ queue_enabled = False
670
+ if isinstance(queue_calls, dict):
671
+ queue_enabled = str(queue_calls.get("enabled", "false")).lower() == "true"
672
+ elif isinstance(queue_calls, str):
673
+ queue_enabled = queue_calls.lower() == "true"
674
+
675
+ # Forward settings
676
+ fwd_no_answer = raw.get("forwardHuntNoAnswer") or {}
677
+ fwd_busy = raw.get("forwardHuntBusy") or {}
678
+
679
+ return MigrationObject(
680
+ canonical_id=canonical_id,
681
+ provenance=_make_provenance(raw, cluster),
682
+ status=MigrationStatus.NORMALIZED,
683
+ pre_migration_state={
684
+ "pattern": pattern,
685
+ "partition": partition,
686
+ "description": raw.get("description"),
687
+ "hunt_list_name": _extract_ref(raw.get("huntListName")),
688
+ "forward_no_answer_destination": fwd_no_answer.get("destination"),
689
+ "forward_no_answer_enabled": str(
690
+ fwd_no_answer.get("enabled", "false")
691
+ ).lower()
692
+ == "true",
693
+ "forward_busy_destination": fwd_busy.get("destination"),
694
+ "forward_busy_enabled": str(
695
+ fwd_busy.get("enabled", "false")
696
+ ).lower()
697
+ == "true",
698
+ "queue_calls_enabled": queue_enabled,
699
+ "max_callers_in_queue": raw.get("maxCallersInQueue"),
700
+ "moh_source_id": raw.get("mohSourceId"),
701
+ "hunt_timer_call_pick": raw.get("huntTimerCallPick"),
702
+ "overflow_destination": raw.get("overflowDestination"),
703
+ "enabled": str(raw.get("enabled", "true")).lower() == "true",
704
+ },
705
+ )
706
+
707
+
708
+ # ---------------------------------------------------------------------------
709
+ # §2.5 — Hunt List normalizer (intermediate)
710
+ # ---------------------------------------------------------------------------
711
+
712
+ def normalize_hunt_list(raw: dict, cluster: str = "default") -> MigrationObject:
713
+ """Normalize a CUCM Hunt List dict into a MigrationObject.
714
+
715
+ (from 02b-cucm-extraction.md §2.5, Hunt List returnedTags)
716
+ """
717
+ name = raw.get("name", "")
718
+
719
+ # Members: list of line group references
720
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
721
+ member_list = _to_list(raw.get("members"), "member")
722
+
723
+ line_groups = []
724
+ for m in member_list:
725
+ # Extractor uses 'userOrDeviceName', design spec uses 'lineGroupName'
726
+ lg_name = (
727
+ _extract_ref(m.get("lineGroupName"))
728
+ or _extract_ref(m.get("userOrDeviceName"))
729
+ )
730
+ if lg_name:
731
+ line_groups.append(lg_name)
732
+
733
+ return MigrationObject(
734
+ canonical_id=f"hunt_list:{name}",
735
+ provenance=_make_provenance(raw, cluster),
736
+ status=MigrationStatus.NORMALIZED,
737
+ pre_migration_state={
738
+ "hunt_list_name": name,
739
+ "description": raw.get("description"),
740
+ "hunt_algorithm": raw.get("huntAlgorithm"),
741
+ "voice_mail_usage": raw.get("voiceMailUsage"),
742
+ "route_list_enabled": raw.get("routeListEnabled"),
743
+ "line_groups": line_groups,
744
+ },
745
+ )
746
+
747
+
748
+ # ---------------------------------------------------------------------------
749
+ # §2.5 — Line Group normalizer (intermediate)
750
+ # ---------------------------------------------------------------------------
751
+
752
+ def normalize_line_group(raw: dict, cluster: str = "default") -> MigrationObject:
753
+ """Normalize a CUCM Line Group dict into a MigrationObject.
754
+
755
+ (from 02b-cucm-extraction.md §2.5, Line Group returnedTags)
756
+ """
757
+ name = raw.get("name", "")
758
+
759
+ # Members: list of DN/line references
760
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
761
+ member_list = _to_list(raw.get("members"), "member")
762
+
763
+ return MigrationObject(
764
+ canonical_id=f"line_group:{name}",
765
+ provenance=_make_provenance(raw, cluster),
766
+ status=MigrationStatus.NORMALIZED,
767
+ pre_migration_state={
768
+ "line_group_name": name,
769
+ "rna_reversion_timeout": raw.get("rnaReversionTimeOut"),
770
+ "distribution_algorithm": raw.get("distributionAlgorithm"),
771
+ "members": member_list,
772
+ },
773
+ )
774
+
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # §2.5 — CTI Route Point normalizer (intermediate, feeds AA creation)
778
+ # ---------------------------------------------------------------------------
779
+
780
+ def normalize_cti_route_point(raw: dict, cluster: str = "default") -> MigrationObject:
781
+ """Normalize a CUCM CTI Route Point dict into a MigrationObject.
782
+
783
+ CTI RPs feed feature_mapper for Auto Attendant creation.
784
+
785
+ (from 02b-cucm-extraction.md §2.5, CTI Route Point returnedTags)
786
+ """
787
+ name = raw.get("name", "")
788
+
789
+ # Lines: same structure as phones
790
+ # Lines: extractor returns flat list, zeep returns {'line': [...]}
791
+ line_entries = _to_list(raw.get("lines"), "line")
792
+
793
+ line_appearances = [
794
+ _normalize_line_appearance(l) for l in line_entries if l.get("dirn")
795
+ ]
796
+
797
+ return MigrationObject(
798
+ canonical_id=f"cti_rp:{name}",
799
+ provenance=_make_provenance(raw, cluster),
800
+ status=MigrationStatus.NORMALIZED,
801
+ pre_migration_state={
802
+ "cti_rp_name": name,
803
+ "description": raw.get("description"),
804
+ "cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
805
+ "cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
806
+ "lines": line_appearances,
807
+ },
808
+ )
809
+
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # §2.5 — Call Park normalizer (intermediate)
813
+ # ---------------------------------------------------------------------------
814
+
815
+ def normalize_call_park(raw: dict, cluster: str = "default") -> MigrationObject:
816
+ """Normalize a CUCM Call Park Number dict into a MigrationObject.
817
+
818
+ (from 02b-cucm-extraction.md §2.5, Call Park returnedTags)
819
+ """
820
+ pattern = raw.get("pattern", "")
821
+ partition = _extract_ref(raw.get("routePartitionName"))
822
+
823
+ return MigrationObject(
824
+ canonical_id=f"call_park:{pattern}:{partition or '<None>'}",
825
+ provenance=_make_provenance(raw, cluster),
826
+ status=MigrationStatus.NORMALIZED,
827
+ pre_migration_state={
828
+ "pattern": pattern,
829
+ "partition": partition,
830
+ "description": raw.get("description"),
831
+ },
832
+ )
833
+
834
+
835
+ # ---------------------------------------------------------------------------
836
+ # §2.5 — Pickup Group normalizer (intermediate)
837
+ # ---------------------------------------------------------------------------
838
+
839
+ def normalize_pickup_group(raw: dict, cluster: str = "default") -> MigrationObject | None:
840
+ """Normalize a CUCM Pickup Group dict into a MigrationObject.
841
+
842
+ Verified via live CUCM: pickup groups use 'pattern' as search criteria,
843
+ and the AXL object has both 'name' and 'pattern' fields.
844
+
845
+ Returns None if the pickup group has no usable name (Fix 7: dedup guard).
846
+
847
+ (from 02b-cucm-extraction.md §2.5, Pickup Group returnedTags)
848
+ """
849
+ # Use name if available, fall back to pattern
850
+ name = raw.get("name", "") or raw.get("pattern", "")
851
+
852
+ # Fix 7: Guard against empty/None name — skip objects with no identity
853
+ if not name or not name.strip():
854
+ return None
855
+
856
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
857
+ # Each member is a dict like {'priority': int, 'someRefField': {'_value_1': name, 'uuid': '{UUID}'}}.
858
+ # Extract UUID strings so downstream consumers (CanonicalPickupGroup.agents) get list[str].
859
+ member_list = _to_list(raw.get("members"), "member")
860
+ member_ids = _extract_member_uuids(member_list)
861
+
862
+ return MigrationObject(
863
+ canonical_id=f"pickup_group:{name}",
864
+ provenance=_make_provenance(raw, cluster),
865
+ status=MigrationStatus.NORMALIZED,
866
+ pre_migration_state={
867
+ "pickup_group_name": name,
868
+ "pattern": raw.get("pattern"),
869
+ "members": member_ids,
870
+ },
871
+ )
872
+
873
+
874
+ # ---------------------------------------------------------------------------
875
+ # §2.5 — Time Schedule normalizer (intermediate)
876
+ # ---------------------------------------------------------------------------
877
+
878
+ def normalize_time_schedule(raw: dict, cluster: str = "default") -> MigrationObject:
879
+ """Normalize a CUCM Time Schedule dict into a MigrationObject.
880
+
881
+ (from 02b-cucm-extraction.md §2.5, Time Schedule returnedTags)
882
+ """
883
+ name = raw.get("name", "")
884
+
885
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
886
+ member_list = _to_list(raw.get("members"), "member")
887
+
888
+ time_period_names = []
889
+ for m in member_list:
890
+ tp_name = _extract_ref(m.get("timePeriodName"))
891
+ if tp_name:
892
+ time_period_names.append(tp_name)
893
+
894
+ return MigrationObject(
895
+ canonical_id=f"time_schedule:{name}",
896
+ provenance=_make_provenance(raw, cluster),
897
+ status=MigrationStatus.NORMALIZED,
898
+ pre_migration_state={
899
+ "schedule_name": name,
900
+ "time_periods": time_period_names,
901
+ },
902
+ )
903
+
904
+
905
+ # ---------------------------------------------------------------------------
906
+ # §2.5 — Time Period normalizer (intermediate)
907
+ # ---------------------------------------------------------------------------
908
+
909
+ def normalize_time_period(raw: dict, cluster: str = "default") -> MigrationObject:
910
+ """Normalize a CUCM Time Period dict into a MigrationObject.
911
+
912
+ (from 02b-cucm-extraction.md §2.5, Time Period returnedTags)
913
+ """
914
+ name = raw.get("name", "")
915
+
916
+ return MigrationObject(
917
+ canonical_id=f"time_period:{name}",
918
+ provenance=_make_provenance(raw, cluster),
919
+ status=MigrationStatus.NORMALIZED,
920
+ pre_migration_state={
921
+ "time_period_name": name,
922
+ "start_time": raw.get("startTime"),
923
+ "end_time": raw.get("endTime"),
924
+ "start_date": raw.get("startDate"),
925
+ "end_date": raw.get("endDate"),
926
+ "month_of_year": raw.get("monthOfYear"),
927
+ "day_of_month": raw.get("dayOfMonth"),
928
+ "day_of_week": raw.get("dayOfWeek"),
929
+ },
930
+ )
931
+
932
+
933
+ # ---------------------------------------------------------------------------
934
+ # §2.4 — Gateway normalizer (intermediate)
935
+ # ---------------------------------------------------------------------------
936
+
937
+ def normalize_gateway(raw: dict, cluster: str = "default") -> MigrationObject:
938
+ """Normalize a CUCM Gateway dict into a MigrationObject.
939
+
940
+ (from 02b-cucm-extraction.md §2.4, Gateway returnedTags)
941
+ """
942
+ name = raw.get("domainName", raw.get("name", ""))
943
+
944
+ return MigrationObject(
945
+ canonical_id=f"gateway:{name}",
946
+ provenance=_make_provenance(raw, cluster),
947
+ status=MigrationStatus.NORMALIZED,
948
+ pre_migration_state={
949
+ "gateway_name": name,
950
+ "description": raw.get("description"),
951
+ "product": raw.get("product"),
952
+ "protocol": raw.get("protocol"),
953
+ "cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
954
+ },
955
+ )
956
+
957
+
958
+ # ---------------------------------------------------------------------------
959
+ # §2.4 — SIP Trunk normalizer (intermediate)
960
+ # ---------------------------------------------------------------------------
961
+
962
+
963
+ def _get_profile_field(profile_detail: dict | None, field_name: str) -> Any:
964
+ """Extract a field from a profile detail dict, returning None if not present."""
965
+ if profile_detail is None:
966
+ return None
967
+ return profile_detail.get(field_name)
968
+
969
+
970
+ def normalize_sip_trunk(raw: dict, cluster: str = "default") -> MigrationObject:
971
+ """Normalize a CUCM SIP Trunk dict into a MigrationObject.
972
+
973
+ (from 02b-cucm-extraction.md §2.4, SIP Trunk returnedTags)
974
+ (from 03b-transform-mappers.md §6, trunk field table)
975
+ """
976
+ name = raw.get("name", "")
977
+
978
+ # Verified via live CUCM: destinations is a list of dicts with
979
+ # {addressIpv4, addressIpv6, port, sortOrder}, NOT flat
980
+ # destinationAddress/destinationPort fields.
981
+ # Extractor returns flat list, zeep returns {'destination': [...]}
982
+ dest_list = _to_list(raw.get("destinations"), "destination")
983
+
984
+ destinations = []
985
+ for d in dest_list:
986
+ if isinstance(d, dict):
987
+ destinations.append({
988
+ "address": d.get("addressIpv4") or d.get("addressIpv6"),
989
+ "port": d.get("port"),
990
+ "sort_order": d.get("sortOrder"),
991
+ })
992
+
993
+ # Fallback: also check flat fields for design-spec format
994
+ if not destinations:
995
+ flat_addr = raw.get("destinationAddress")
996
+ flat_port = raw.get("destinationPort")
997
+ if flat_addr:
998
+ destinations = [{"address": flat_addr, "port": flat_port, "sort_order": 1}]
999
+
1000
+ return MigrationObject(
1001
+ canonical_id=f"sip_trunk:{name}",
1002
+ provenance=_make_provenance(raw, cluster),
1003
+ status=MigrationStatus.NORMALIZED,
1004
+ pre_migration_state={
1005
+ "trunk_name": name,
1006
+ "description": raw.get("description"),
1007
+ "destinations": destinations,
1008
+ "max_num_calls": raw.get("maxNumCalls"),
1009
+ "cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
1010
+ "sip_profile": _extract_ref(raw.get("sipProfileName")),
1011
+ "security_profile": _extract_ref(raw.get("securityProfileName")),
1012
+ "sip_trunk_type": raw.get("sipTrunkType"),
1013
+ "dual_identity_support": raw.get("dualIdentitySupportEnabled"),
1014
+ "sip_profile_early_offer": _get_profile_field(raw.get("_sip_profile_detail"), "earlyOfferSupportForVoiceCall"),
1015
+ "sip_profile_srtp_fallback": _get_profile_field(raw.get("_sip_profile_detail"), "srtpFallbackAllowed"),
1016
+ "security_mode": _get_profile_field(raw.get("_security_profile_detail"), "securityMode"),
1017
+ "security_digest_auth": _get_profile_field(raw.get("_security_profile_detail"), "enableDigestAuthentication"),
1018
+ },
1019
+ )
1020
+
1021
+
1022
+ # ---------------------------------------------------------------------------
1023
+ # §2.4 — Route Group normalizer (intermediate)
1024
+ # ---------------------------------------------------------------------------
1025
+
1026
+ def normalize_route_group(raw: dict, cluster: str = "default") -> MigrationObject:
1027
+ """Normalize a CUCM Route Group dict into a MigrationObject.
1028
+
1029
+ (from 02b-cucm-extraction.md §2.4, Route Group returnedTags)
1030
+ """
1031
+ name = raw.get("name", "")
1032
+
1033
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
1034
+ member_list = _to_list(raw.get("members"), "member")
1035
+
1036
+ trunks = []
1037
+ for m in member_list:
1038
+ # Extractor uses 'sipTrunkName', design spec uses 'deviceName'
1039
+ trunk_name = (
1040
+ _extract_ref(m.get("deviceName"))
1041
+ or _extract_ref(m.get("sipTrunkName"))
1042
+ )
1043
+ if trunk_name:
1044
+ trunks.append({
1045
+ "trunk_name": trunk_name,
1046
+ "priority": m.get("priority", 1),
1047
+ })
1048
+
1049
+ return MigrationObject(
1050
+ canonical_id=f"route_group:{name}",
1051
+ provenance=_make_provenance(raw, cluster),
1052
+ status=MigrationStatus.NORMALIZED,
1053
+ pre_migration_state={
1054
+ "route_group_name": name,
1055
+ "description": raw.get("description"),
1056
+ "trunks": trunks,
1057
+ },
1058
+ )
1059
+
1060
+
1061
+ # ---------------------------------------------------------------------------
1062
+ # §2.4 — Route List normalizer (intermediate)
1063
+ # ---------------------------------------------------------------------------
1064
+
1065
+ def normalize_route_list(raw: dict, cluster: str = "default") -> MigrationObject:
1066
+ """Normalize a CUCM Route List dict into a MigrationObject.
1067
+
1068
+ (from 02b-cucm-extraction.md §2.4, Route List returnedTags)
1069
+ """
1070
+ name = raw.get("name", "")
1071
+
1072
+ # Members: extractor returns flat list, zeep returns {'member': [...]}
1073
+ member_list = _to_list(raw.get("members"), "member")
1074
+
1075
+ route_groups = []
1076
+ for m in member_list:
1077
+ rg_name = _extract_ref(m.get("routeGroupName"))
1078
+ if rg_name:
1079
+ route_groups.append(rg_name)
1080
+
1081
+ return MigrationObject(
1082
+ canonical_id=f"route_list:{name}",
1083
+ provenance=_make_provenance(raw, cluster),
1084
+ status=MigrationStatus.NORMALIZED,
1085
+ pre_migration_state={
1086
+ "route_list_name": name,
1087
+ "description": raw.get("description"),
1088
+ "route_groups": route_groups,
1089
+ },
1090
+ )
1091
+
1092
+
1093
+ # ---------------------------------------------------------------------------
1094
+ # §2.6 — Voicemail Profile normalizer
1095
+ # ---------------------------------------------------------------------------
1096
+
1097
+ def _extract_vm_pilot(field: dict | str | None) -> str | None:
1098
+ """Extract the voicemail pilot number from the voiceMailPilot field.
1099
+
1100
+ Live CUCM returns voiceMailPilot as a complex type {dirn, cssName, uuid},
1101
+ NOT the simple {_value_1, uuid} reference pattern. The pilot number is
1102
+ in the 'dirn' key.
1103
+ (Verified via live CUCM Phase 03 — 02b spec had this wrong)
1104
+ """
1105
+ if field is None:
1106
+ return None
1107
+ if isinstance(field, dict):
1108
+ # Real structure: {dirn: str, cssName: str, uuid: str}
1109
+ if "dirn" in field:
1110
+ return field["dirn"]
1111
+ # Fallback: design spec structure {_value_1: str, uuid: str}
1112
+ if "_value_1" in field:
1113
+ return field["_value_1"]
1114
+ if isinstance(field, str):
1115
+ return field
1116
+ return None
1117
+
1118
+
1119
+ def normalize_voicemail_profile(
1120
+ raw: dict, cluster: str = "default"
1121
+ ) -> CanonicalVoicemailProfile:
1122
+ """Normalize a CUCM Voicemail Profile dict into a CanonicalVoicemailProfile.
1123
+
1124
+ (from 02b-cucm-extraction.md §2.6, Voicemail Profile mapping)
1125
+ (from 03b-transform-mappers.md §9, voicemail_mapper field table)
1126
+ """
1127
+ name = raw.get("name", "")
1128
+ vm_pilot_field = raw.get("voiceMailPilot")
1129
+
1130
+ return CanonicalVoicemailProfile(
1131
+ canonical_id=f"voicemail_profile:{name}",
1132
+ provenance=_make_provenance(raw, cluster),
1133
+ status=MigrationStatus.NORMALIZED,
1134
+ cucm_voicemail_profile_name=name,
1135
+ pre_migration_state={
1136
+ "description": raw.get("description"),
1137
+ "voicemail_pilot": _extract_vm_pilot(vm_pilot_field),
1138
+ "voicemail_pilot_css": vm_pilot_field.get("cssName")
1139
+ if isinstance(vm_pilot_field, dict)
1140
+ else None,
1141
+ "voicemail_box_mask": raw.get("voiceMailboxMask"),
1142
+ },
1143
+ )
1144
+
1145
+
1146
+ # ---------------------------------------------------------------------------
1147
+ # §2.6 — Unity Connection per-user VM settings normalizer
1148
+ # ---------------------------------------------------------------------------
1149
+
1150
+ def normalize_unity_vm_settings(
1151
+ userid: str, settings: dict, cluster: str = "default"
1152
+ ) -> MigrationObject:
1153
+ """Normalize Unity Connection per-user voicemail settings into a MigrationObject.
1154
+
1155
+ These are the ~12 per-user fields from extract_user_vm_settings() in
1156
+ unity_connection.py. Stored separately from the AXL voicemail profile
1157
+ because profiles are shared (many users → one profile) while UC settings
1158
+ are per-user.
1159
+
1160
+ The voicemail_mapper (Phase 05) joins these with the profile-level data
1161
+ via cross_ref user_has_voicemail_profile + these per-user settings.
1162
+
1163
+ (from Phase 03 unity_connection.py extract_user_vm_settings())
1164
+ """
1165
+ return MigrationObject(
1166
+ canonical_id=f"unity_vm:{userid}",
1167
+ provenance=Provenance(
1168
+ source_system="unity_connection",
1169
+ source_id=settings.get("uc_object_id", ""),
1170
+ source_name=userid,
1171
+ cluster=cluster,
1172
+ extracted_at=_now(),
1173
+ ),
1174
+ status=MigrationStatus.NORMALIZED,
1175
+ pre_migration_state={
1176
+ "userid": userid,
1177
+ "vm_enabled": settings.get("vm_enabled", False),
1178
+ "call_handler_id": settings.get("call_handler_id"),
1179
+ # Notification settings
1180
+ "notification_enabled": settings.get("notification_enabled", False),
1181
+ "notification_destination": settings.get("notification_destination"),
1182
+ # Call handler transfer rules
1183
+ "send_all_calls": settings.get("send_all_calls"),
1184
+ "send_busy_calls": settings.get("send_busy_calls"),
1185
+ "send_unanswered_calls": settings.get("send_unanswered_calls"),
1186
+ "unanswered_rings": settings.get("unanswered_rings"),
1187
+ "busy_greeting_type": settings.get("busy_greeting_type"),
1188
+ # Message store
1189
+ "storage_type": settings.get("storage_type"),
1190
+ "mwi_enabled": settings.get("mwi_enabled"),
1191
+ "external_email": settings.get("external_email"),
1192
+ "email_copy_enabled": settings.get("email_copy_enabled"),
1193
+ # Transfer to zero
1194
+ "transfer_to_zero_enabled": settings.get("transfer_to_zero_enabled"),
1195
+ "transfer_to_zero_destination": settings.get(
1196
+ "transfer_to_zero_destination"
1197
+ ),
1198
+ # Fax
1199
+ "fax_enabled": settings.get("fax_enabled"),
1200
+ "fax_number": settings.get("fax_number"),
1201
+ },
1202
+ )
1203
+
1204
+
1205
+ # ---------------------------------------------------------------------------
1206
+ # §2.6 — Unity Connection shared/group mailbox normalizer
1207
+ # (from docs/superpowers/specs/2026-04-10-voicemail-groups.md)
1208
+ # ---------------------------------------------------------------------------
1209
+
1210
+
1211
+ def normalize_voicemail_group(
1212
+ raw: dict, cluster: str = "default"
1213
+ ) -> MigrationObject | None:
1214
+ """Normalize a Unity Connection shared mailbox (call handler) into a
1215
+ MigrationObject for later consumption by VoicemailGroupMapper.
1216
+
1217
+ Returns None when DisplayName is missing — shared mailboxes without a
1218
+ display name can't be mapped reliably and should be skipped.
1219
+
1220
+ (from Unity Connection CUPI /vmrest/handlers/callhandlers)
1221
+ """
1222
+ name = raw.get("DisplayName") or raw.get("display_name") or ""
1223
+ if not name:
1224
+ return None
1225
+
1226
+ extension = (
1227
+ raw.get("DtmfAccessId")
1228
+ or raw.get("Extension")
1229
+ or raw.get("extension")
1230
+ )
1231
+ object_id = raw.get("ObjectId") or raw.get("pkid") or ""
1232
+
1233
+ return MigrationObject(
1234
+ canonical_id=f"voicemail_group:{name}",
1235
+ provenance=Provenance(
1236
+ source_system="unity_connection",
1237
+ source_id=object_id,
1238
+ source_name=name,
1239
+ cluster=cluster,
1240
+ extracted_at=_now(),
1241
+ ),
1242
+ status=MigrationStatus.NORMALIZED,
1243
+ pre_migration_state={
1244
+ "name": name,
1245
+ "extension": str(extension) if extension is not None else None,
1246
+ "cucm_object_id": object_id,
1247
+ "notification_destination": (
1248
+ raw.get("SmtpAddress")
1249
+ or raw.get("notification_destination")
1250
+ ),
1251
+ "language_code": raw.get("language_code", "en_us"),
1252
+ "greeting_type": raw.get("greeting_type", "DEFAULT"),
1253
+ "transfer_destination": raw.get("transfer_destination"),
1254
+ "fax_enabled": bool(raw.get("fax_enabled")),
1255
+ "fax_number": raw.get("fax_number"),
1256
+ },
1257
+ )
1258
+
1259
+
1260
+ # ---------------------------------------------------------------------------
1261
+ # §2.6 — Voicemail Pilot normalizer (intermediate, for UUID→dirn resolution)
1262
+ # ---------------------------------------------------------------------------
1263
+
1264
+ def normalize_voicemail_pilot(raw: dict, cluster: str = "default") -> MigrationObject:
1265
+ """Normalize a CUCM Voicemail Pilot dict into a MigrationObject.
1266
+
1267
+ Voicemail pilots are intermediate objects used to resolve the
1268
+ voiceMailPilot.uuid on profiles to an actual pilot dirn number.
1269
+ The profile's voiceMailPilot.dirn is often None — the UUID must be
1270
+ matched against these pilot objects to get the actual dirn.
1271
+ (Confirmed by Phase 03 live CUCM validation)
1272
+ """
1273
+ dirn = raw.get("dirn", "")
1274
+ pkid = raw.get("pkid", "")
1275
+ return MigrationObject(
1276
+ canonical_id=f"voicemail_pilot:{dirn or pkid}",
1277
+ provenance=_make_provenance(raw, cluster),
1278
+ status=MigrationStatus.NORMALIZED,
1279
+ pre_migration_state={
1280
+ "dirn": dirn,
1281
+ "description": raw.get("description"),
1282
+ # Store UUID for profile→pilot resolution
1283
+ "uuid": pkid,
1284
+ },
1285
+ )
1286
+
1287
+
1288
+ # ---------------------------------------------------------------------------
1289
+ # §2.1 — CUCM Location normalizer (CAC bandwidth objects, no address data)
1290
+ # ---------------------------------------------------------------------------
1291
+
1292
+ def normalize_cucm_location(raw: dict, cluster: str = "default") -> MigrationObject:
1293
+ """Normalize a CUCM Location entity (CAC object) into a MigrationObject.
1294
+
1295
+ These are Call Admission Control bandwidth objects, NOT physical locations.
1296
+ No address fields exist. The location_mapper's MISSING_DATA decision for
1297
+ addresses is the correct path.
1298
+ (Confirmed by Phase 03 live CUCM validation: shape is {name, id, uuid,
1299
+ withinAudioBandwidth, withinVideoBandwidth, withinImmersiveKbits})
1300
+ """
1301
+ name = raw.get("name", "")
1302
+ return MigrationObject(
1303
+ canonical_id=f"cucm_location:{name}",
1304
+ provenance=_make_provenance(raw, cluster),
1305
+ status=MigrationStatus.NORMALIZED,
1306
+ pre_migration_state={
1307
+ "cucm_location_name": name,
1308
+ # No address fields — confirmed via live CUCM
1309
+ },
1310
+ )
1311
+
1312
+
1313
+ # ---------------------------------------------------------------------------
1314
+ # §2.1 — Phone Button Template normalizer
1315
+ # ---------------------------------------------------------------------------
1316
+
1317
+ def normalize_button_template(raw: dict, cluster: str = "default") -> MigrationObject | None:
1318
+ """Normalize a CUCM PhoneButtonTemplate dict.
1319
+
1320
+ (from tier2-phase2-phone-config-design.md §2.1)
1321
+ """
1322
+ name = raw.get("name")
1323
+ if not name:
1324
+ return None
1325
+
1326
+ raw_buttons = raw.get("buttons")
1327
+ button_list = _to_list(raw_buttons, "button") if isinstance(raw_buttons, dict) else []
1328
+ buttons = []
1329
+ for btn in button_list:
1330
+ if not isinstance(btn, dict):
1331
+ continue
1332
+ # AXL getPhoneButtonTemplate uses <buttonNumber>, not <index>
1333
+ idx = btn.get("buttonNumber") or btn.get("index")
1334
+ feature = btn.get("feature", "")
1335
+ buttons.append({"index": int(idx) if idx else 0, "feature": feature})
1336
+ buttons.sort(key=lambda b: b["index"])
1337
+
1338
+ return MigrationObject(
1339
+ canonical_id=f"button_template:{name}",
1340
+ provenance=_make_provenance(raw, cluster),
1341
+ status=MigrationStatus.NORMALIZED,
1342
+ pre_migration_state={
1343
+ "name": name,
1344
+ "base_template": _extract_ref(raw.get("basePhoneTemplateName")),
1345
+ "buttons": buttons,
1346
+ },
1347
+ )
1348
+
1349
+
1350
+ # ---------------------------------------------------------------------------
1351
+ # §2.2 — Softkey Template normalizer
1352
+ # ---------------------------------------------------------------------------
1353
+
1354
+ def normalize_softkey_template(raw: dict, cluster: str = "default") -> MigrationObject | None:
1355
+ """Normalize a CUCM SoftkeyTemplate dict.
1356
+
1357
+ (from tier2-phase2-phone-config-design.md §2.2)
1358
+ """
1359
+ name = raw.get("name")
1360
+ if not name:
1361
+ return None
1362
+
1363
+ # Softkey templates come from SQL (AXL operations don't exist in v15.0).
1364
+ # The TemplateExtractor pre-parses SQL rows into structured dicts with
1365
+ # call_states already decoded. Pass through the parsed data.
1366
+ call_states = raw.get("call_states", {})
1367
+ available_softkeys = raw.get("available_softkeys", [])
1368
+
1369
+ return MigrationObject(
1370
+ canonical_id=f"softkey_template:{name}",
1371
+ provenance=_make_provenance(raw, cluster),
1372
+ status=MigrationStatus.NORMALIZED,
1373
+ pre_migration_state={
1374
+ "name": name,
1375
+ "description": raw.get("description") or "",
1376
+ "default_template": (
1377
+ raw.get("base_template_pkid")
1378
+ or _extract_ref(raw.get("defaultSoftkeyTemplateName"))
1379
+ or ""
1380
+ ),
1381
+ "call_states": call_states,
1382
+ "available_softkeys": available_softkeys,
1383
+ },
1384
+ )
1385
+
1386
+
1387
+ # ---------------------------------------------------------------------------
1388
+ # Device Profile normalizer (Tier 2 — §2.2 Extension Mobility)
1389
+ # ---------------------------------------------------------------------------
1390
+
1391
+ def normalize_device_profile(
1392
+ raw: dict[str, Any],
1393
+ cluster: str = "unknown",
1394
+ ) -> MigrationObject:
1395
+ """Normalize a CUCM Device Profile to a MigrationObject.
1396
+
1397
+ (from tier2-enterprise-expansion.md §5)
1398
+ """
1399
+ name = raw.get("name") or ""
1400
+ product = raw.get("product") or ""
1401
+ protocol = raw.get("protocol") or ""
1402
+ device_pool = _extract_ref(raw.get("devicePoolName"))
1403
+
1404
+ # Extract line info
1405
+ lines_raw = raw.get("lines") or []
1406
+ if isinstance(lines_raw, dict):
1407
+ lines_raw = lines_raw.get("line", [])
1408
+ if isinstance(lines_raw, dict):
1409
+ lines_raw = [lines_raw]
1410
+
1411
+ lines = []
1412
+ for line in (lines_raw if isinstance(lines_raw, list) else []):
1413
+ if not isinstance(line, dict):
1414
+ continue
1415
+ dirn = line.get("dirn") or {}
1416
+ if isinstance(dirn, dict):
1417
+ pattern = dirn.get("pattern") or ""
1418
+ partition = _extract_ref(dirn.get("routePartitionName"))
1419
+ else:
1420
+ pattern = str(dirn)
1421
+ partition = None
1422
+ lines.append({
1423
+ "dn_pattern": pattern,
1424
+ "partition": partition,
1425
+ "index": line.get("index"),
1426
+ })
1427
+
1428
+ # Count speed dials and BLFs
1429
+ sd = raw.get("speeddials") or []
1430
+ blf = raw.get("busyLampFields") or []
1431
+ sd_count = len(sd) if isinstance(sd, list) else 0
1432
+ blf_count = len(blf) if isinstance(blf, list) else 0
1433
+
1434
+ return MigrationObject(
1435
+ canonical_id=f"device_profile:{name}",
1436
+ provenance=_make_provenance(raw, cluster),
1437
+ status=MigrationStatus.NORMALIZED,
1438
+ pre_migration_state={
1439
+ "profile_name": name,
1440
+ "model": product,
1441
+ "protocol": protocol,
1442
+ "device_pool_name": device_pool,
1443
+ "lines": lines,
1444
+ "speed_dial_count": sd_count,
1445
+ "blf_count": blf_count,
1446
+ },
1447
+ )
1448
+
1449
+
1450
+ # ---------------------------------------------------------------------------
1451
+ # E911 normalizers (Tier 2 — §2.6 E911/ELIN)
1452
+ # ---------------------------------------------------------------------------
1453
+
1454
+ def normalize_elin_group(
1455
+ raw: dict[str, Any],
1456
+ cluster: str = "unknown",
1457
+ ) -> MigrationObject:
1458
+ """Normalize a CUCM ELIN Group to a MigrationObject.
1459
+
1460
+ (from tier2-enterprise-expansion.md §6)
1461
+ """
1462
+ name = raw.get("name") or ""
1463
+ # ELIN numbers may come as a nested structure or flat list
1464
+ elin_raw = raw.get("elinNumbers") or raw.get("elinNumber") or []
1465
+ elin_numbers = []
1466
+ if isinstance(elin_raw, list):
1467
+ for e in elin_raw:
1468
+ if isinstance(e, str):
1469
+ elin_numbers.append(e)
1470
+ elif isinstance(e, dict):
1471
+ num = e.get("_value_1") or e.get("number") or e.get("dirn") or ""
1472
+ if num:
1473
+ elin_numbers.append(str(num))
1474
+ elif isinstance(elin_raw, str) and elin_raw:
1475
+ elin_numbers = [elin_raw]
1476
+
1477
+ return MigrationObject(
1478
+ canonical_id=f"elin_group:{name}",
1479
+ provenance=_make_provenance(raw, cluster),
1480
+ status=MigrationStatus.NORMALIZED,
1481
+ pre_migration_state={
1482
+ "name": name,
1483
+ "description": raw.get("description") or "",
1484
+ "elin_numbers": elin_numbers,
1485
+ },
1486
+ )
1487
+
1488
+
1489
+ def normalize_geo_location(
1490
+ raw: dict[str, Any],
1491
+ cluster: str = "unknown",
1492
+ ) -> MigrationObject:
1493
+ """Normalize a CUCM Geographic Location to a MigrationObject.
1494
+
1495
+ (from tier2-enterprise-expansion.md §6)
1496
+ """
1497
+ name = raw.get("name") or ""
1498
+ return MigrationObject(
1499
+ canonical_id=f"geo_location:{name}",
1500
+ provenance=_make_provenance(raw, cluster),
1501
+ status=MigrationStatus.NORMALIZED,
1502
+ pre_migration_state={
1503
+ "name": name,
1504
+ "description": raw.get("description") or "",
1505
+ "country": raw.get("country") or "",
1506
+ },
1507
+ )
1508
+
1509
+
1510
+ # ---------------------------------------------------------------------------
1511
+ # Remote Destination normalizer (Tier 2 — §2.7 SNR)
1512
+ # ---------------------------------------------------------------------------
1513
+
1514
+ def normalize_remote_destination(
1515
+ raw: dict[str, Any],
1516
+ cluster: str = "unknown",
1517
+ ) -> MigrationObject:
1518
+ """Normalize a CUCM Remote Destination to a MigrationObject.
1519
+
1520
+ Preserves raw fields for the SNR mapper to consume.
1521
+ (from tier2-enterprise-expansion.md §4)
1522
+ """
1523
+ name = raw.get("name") or ""
1524
+ owner = raw.get("ownerUserId") or ""
1525
+ dest = raw.get("destination") or ""
1526
+
1527
+ return MigrationObject(
1528
+ canonical_id=f"remote_destination:{owner}:{name}" if owner else f"remote_destination:{name}",
1529
+ provenance=_make_provenance(raw, cluster),
1530
+ status=MigrationStatus.NORMALIZED,
1531
+ pre_migration_state={
1532
+ "name": name,
1533
+ "destination": dest,
1534
+ "ownerUserId": owner,
1535
+ "isMobilePhone": raw.get("isMobilePhone"),
1536
+ "enableMobileConnect": raw.get("enableMobileConnect"),
1537
+ "answerTooSoonTimer": raw.get("answerTooSoonTimer"),
1538
+ "answerTooLateTimer": raw.get("answerTooLateTimer"),
1539
+ "lineAssociations": raw.get("lineAssociations"),
1540
+ "remoteDestinationProfileName": raw.get("remoteDestinationProfileName"),
1541
+ },
1542
+ )
1543
+
1544
+
1545
+ # ---------------------------------------------------------------------------
1546
+ # MOH normalizer (Tier 2 — §2.3 MOH Sources)
1547
+ # ---------------------------------------------------------------------------
1548
+
1549
+ def normalize_moh_source(
1550
+ raw: dict[str, Any],
1551
+ cluster: str = "unknown",
1552
+ ) -> MigrationObject:
1553
+ """Normalize a CUCM MOH Audio Source to a MigrationObject.
1554
+
1555
+ Preserves raw fields for the MOH mapper to consume.
1556
+ (from tier2-enterprise-expansion.md §2.3)
1557
+ """
1558
+ name = raw.get("name") or ""
1559
+ source_file = raw.get("sourceFileName") or ""
1560
+ is_default_raw = raw.get("isDefault")
1561
+ # AXL returns isDefault as string "true"/"false" or bool
1562
+ if isinstance(is_default_raw, str):
1563
+ is_default = is_default_raw.lower() == "true"
1564
+ else:
1565
+ is_default = bool(is_default_raw) if is_default_raw is not None else False
1566
+ source_id = raw.get("sourceId") or ""
1567
+
1568
+ return MigrationObject(
1569
+ canonical_id=f"moh_source:{name}",
1570
+ provenance=_make_provenance(raw, cluster),
1571
+ status=MigrationStatus.NORMALIZED,
1572
+ pre_migration_state={
1573
+ "name": name,
1574
+ "source_file_name": source_file,
1575
+ "is_default": is_default,
1576
+ "source_id": source_id,
1577
+ },
1578
+ )
1579
+
1580
+
1581
+ # ---------------------------------------------------------------------------
1582
+ # Announcement normalizer (Tier 2 — §2.4 Announcements)
1583
+ # ---------------------------------------------------------------------------
1584
+
1585
+ def normalize_announcement(
1586
+ raw: dict[str, Any],
1587
+ cluster: str = "unknown",
1588
+ ) -> MigrationObject:
1589
+ """Normalize a CUCM Announcement to a MigrationObject.
1590
+
1591
+ Preserves name, description, and file name for the announcement mapper.
1592
+ (from tier2-enterprise-expansion.md §2.4)
1593
+ """
1594
+ name = raw.get("name") or ""
1595
+ file_name = raw.get("announcementFile") or ""
1596
+
1597
+ return MigrationObject(
1598
+ canonical_id=f"announcement:{name}",
1599
+ provenance=_make_provenance(raw, cluster),
1600
+ status=MigrationStatus.NORMALIZED,
1601
+ pre_migration_state={
1602
+ "name": name,
1603
+ "description": raw.get("description") or "",
1604
+ "file_name": file_name,
1605
+ },
1606
+ )
1607
+
1608
+
1609
+ # ---------------------------------------------------------------------------
1610
+ # Tier 4 informational normalizers (Wave 1: extract + flag, no mappers)
1611
+ # ---------------------------------------------------------------------------
1612
+
1613
+ def normalize_recording_profile(
1614
+ raw: dict[str, Any],
1615
+ cluster: str = "unknown",
1616
+ ) -> MigrationObject:
1617
+ """Normalize a CUCM Recording Profile to informational MigrationObject."""
1618
+ name = raw.get("name") or ""
1619
+ return MigrationObject(
1620
+ canonical_id=f"info_recording:{name}",
1621
+ provenance=_make_provenance(raw, cluster),
1622
+ status=MigrationStatus.NORMALIZED,
1623
+ pre_migration_state={
1624
+ "name": name,
1625
+ "recordingCssName": raw.get("recordingCssName") or "",
1626
+ "recorderDestination": raw.get("recorderDestination") or "",
1627
+ },
1628
+ )
1629
+
1630
+
1631
+ def normalize_calling_party_xform(
1632
+ raw: dict[str, Any],
1633
+ cluster: str = "unknown",
1634
+ ) -> MigrationObject:
1635
+ """Normalize a CUCM Calling Party Transformation Pattern."""
1636
+ pattern = raw.get("pattern") or ""
1637
+ partition = raw.get("routePartitionName") or ""
1638
+ return MigrationObject(
1639
+ canonical_id=f"info_calling_xform:{pattern}:{partition}" if partition else f"info_calling_xform:{pattern}",
1640
+ provenance=_make_provenance(raw, cluster),
1641
+ status=MigrationStatus.NORMALIZED,
1642
+ pre_migration_state={
1643
+ "pattern": pattern,
1644
+ "description": raw.get("description") or "",
1645
+ "callingSearchSpaceName": raw.get("callingSearchSpaceName") or "",
1646
+ "routePartitionName": partition,
1647
+ "callingPartyTransformationMask": raw.get("callingPartyTransformationMask") or "",
1648
+ "callingPartyPrefixDigits": raw.get("callingPartyPrefixDigits") or "",
1649
+ "digitDiscardInstructionName": raw.get("digitDiscardInstructionName") or "",
1650
+ },
1651
+ )
1652
+
1653
+
1654
+ def normalize_called_party_xform(
1655
+ raw: dict[str, Any],
1656
+ cluster: str = "unknown",
1657
+ ) -> MigrationObject:
1658
+ """Normalize a CUCM Called Party Transformation Pattern."""
1659
+ pattern = raw.get("pattern") or ""
1660
+ partition = raw.get("routePartitionName") or ""
1661
+ return MigrationObject(
1662
+ canonical_id=f"info_called_xform:{pattern}:{partition}" if partition else f"info_called_xform:{pattern}",
1663
+ provenance=_make_provenance(raw, cluster),
1664
+ status=MigrationStatus.NORMALIZED,
1665
+ pre_migration_state={
1666
+ "pattern": pattern,
1667
+ "description": raw.get("description") or "",
1668
+ "callingSearchSpaceName": raw.get("callingSearchSpaceName") or "",
1669
+ "routePartitionName": partition,
1670
+ "calledPartyTransformationMask": raw.get("calledPartyTransformationMask") or "",
1671
+ "calledPartyPrefixDigits": raw.get("calledPartyPrefixDigits") or "",
1672
+ "digitDiscardInstructionName": raw.get("digitDiscardInstructionName") or "",
1673
+ },
1674
+ )
1675
+
1676
+
1677
+ def normalize_info_device_profile(
1678
+ raw: dict[str, Any],
1679
+ cluster: str = "unknown",
1680
+ ) -> MigrationObject:
1681
+ """Normalize a CUCM Device Profile (Extension Mobility) — Tier 4 informational.
1682
+
1683
+ Note: NOT the same as the Tier 2 `normalize_device_profile` which creates
1684
+ `device_profile:*` objects for the full mapper pipeline. This creates
1685
+ `info_device_profile:*` objects for report-only flagging.
1686
+ """
1687
+ name = raw.get("name") or ""
1688
+ return MigrationObject(
1689
+ canonical_id=f"info_device_profile:{name}",
1690
+ provenance=_make_provenance(raw, cluster),
1691
+ status=MigrationStatus.NORMALIZED,
1692
+ pre_migration_state={
1693
+ "name": name,
1694
+ "description": raw.get("description") or "",
1695
+ "product": raw.get("product") or "",
1696
+ "protocol": raw.get("protocol") or "",
1697
+ },
1698
+ )
1699
+
1700
+
1701
+
1702
+
1703
+ def normalize_intercept_candidate(
1704
+ raw: dict[str, Any],
1705
+ cluster: str = "unknown",
1706
+ ) -> MigrationObject:
1707
+ """Normalize a CUCM intercept candidate signal."""
1708
+ dn = raw.get("dn") or ""
1709
+ partition = raw.get("partition") or "<None>"
1710
+ return MigrationObject(
1711
+ canonical_id=f"intercept_candidate:{dn}:{partition}",
1712
+ provenance=_make_provenance(raw, cluster),
1713
+ status=MigrationStatus.NORMALIZED,
1714
+ pre_migration_state={
1715
+ "userid": raw.get("userid") or "",
1716
+ "dn": dn,
1717
+ "partition": partition if partition != "<None>" else "",
1718
+ "signal_type": raw.get("signal_type") or "unknown",
1719
+ "forward_destination": raw.get("forward_destination") or "",
1720
+ "voicemail_enabled": raw.get("voicemail_enabled", False),
1721
+ },
1722
+ )
1723
+
1724
+
1725
+ # ---------------------------------------------------------------------------
1726
+ # Tier 3: Informational type normalizers (generic factory)
1727
+ # ---------------------------------------------------------------------------
1728
+
1729
+ def _make_info_normalizer(suffix: str):
1730
+ """Factory: creates a normalizer for an informational type."""
1731
+ if suffix == "app_user":
1732
+ name_field = "userid"
1733
+ elif suffix == "intercom":
1734
+ name_field = "dnorpattern"
1735
+ else:
1736
+ name_field = "name"
1737
+
1738
+ def normalizer(item: dict, cluster: str = "default") -> MigrationObject | None:
1739
+ name_val = item.get(name_field)
1740
+ if not name_val:
1741
+ return None
1742
+ if isinstance(name_val, dict):
1743
+ name_val = name_val.get("_value_1", str(name_val))
1744
+ return MigrationObject(
1745
+ canonical_id=f"info_{suffix}:{name_val}",
1746
+ provenance=Provenance(
1747
+ source_system="cucm",
1748
+ source_id=item.get("pkid", ""),
1749
+ source_name=str(name_val),
1750
+ cluster=cluster,
1751
+ extracted_at=_now(),
1752
+ ),
1753
+ status=MigrationStatus.NORMALIZED,
1754
+ pre_migration_state=item,
1755
+ )
1756
+
1757
+ return normalizer
1758
+
1759
+
1760
+ _INFO_SUFFIXES = [
1761
+ "region", "srst", "media_resource_group", "media_resource_list",
1762
+ "aar_group", "device_mobility_group", "conference_bridge",
1763
+ "softkey_template", "ip_phone_service", "intercom",
1764
+ "common_phone_config", "phone_button_template",
1765
+ "feature_control_policy", "credential_policy",
1766
+ "recording_profile", "ldap_directory",
1767
+ "app_user", "h323_gateway", "enterprise_params", "service_params",
1768
+ ]
1769
+
1770
+
1771
+ # ---------------------------------------------------------------------------
1772
+ # Executive/Assistant normalizers
1773
+ # ---------------------------------------------------------------------------
1774
+
1775
+ def normalize_executive_assistant_pair(
1776
+ raw: dict, cluster: str = "default",
1777
+ ) -> MigrationObject:
1778
+ """Normalize an executive/assistant relationship pair from SQL query."""
1779
+ exec_userid = raw.get("executive_userid", "")
1780
+ asst_userid = raw.get("assistant_userid", "")
1781
+ return MigrationObject(
1782
+ canonical_id=f"exec_asst_pair:{exec_userid}:{asst_userid}",
1783
+ provenance=_make_provenance(raw, cluster),
1784
+ status=MigrationStatus.NORMALIZED,
1785
+ pre_migration_state={
1786
+ "executive_userid": exec_userid,
1787
+ "assistant_userid": asst_userid,
1788
+ "executive_pkid": raw.get("executive_pkid"),
1789
+ "assistant_pkid": raw.get("assistant_pkid"),
1790
+ },
1791
+ )
1792
+
1793
+
1794
+ def normalize_executive_settings(
1795
+ raw: dict, cluster: str = "default",
1796
+ ) -> MigrationObject:
1797
+ """Normalize executive/assistant user service subscription."""
1798
+ userid = raw.get("userid", "")
1799
+ service_name = raw.get("service_name", "")
1800
+ role = "EXECUTIVE" if service_name == "Executive" else "EXECUTIVE_ASSISTANT"
1801
+ return MigrationObject(
1802
+ canonical_id=f"exec_setting:{userid}",
1803
+ provenance=_make_provenance(raw, cluster),
1804
+ status=MigrationStatus.NORMALIZED,
1805
+ pre_migration_state={
1806
+ "userid": userid,
1807
+ "role": role,
1808
+ "service_name": service_name,
1809
+ "servicetype": raw.get("servicetype"),
1810
+ },
1811
+ )
1812
+
1813
+
1814
+ # ---------------------------------------------------------------------------
1815
+ # Post-normalization: DECT network grouping
1816
+ # ---------------------------------------------------------------------------
1817
+
1818
+ def normalize_dect_group(
1819
+ store: Any,
1820
+ dect_inventory: list[dict[str, str]] | None = None,
1821
+ ) -> list[CanonicalDECTNetwork]:
1822
+ """Group DECT handsets by device pool and create CanonicalDECTNetwork objects.
1823
+
1824
+ Post-normalization step — runs after Pass 2 CrossReferenceBuilder so that
1825
+ compatibility_tier has been classified on each CanonicalDevice. Reads all
1826
+ devices with compatibility_tier == "dect", groups them by device pool name,
1827
+ and creates one CanonicalDECTNetwork per group.
1828
+
1829
+ The mapper (DECTMapper, Task 3) will enrich each network with location refs,
1830
+ handset owner resolution, and access codes.
1831
+
1832
+ Args:
1833
+ store: MigrationStore instance with normalized + cross-ref'd objects.
1834
+ dect_inventory: Optional list of base station dicts from the
1835
+ --dect-inventory CLI flag. Each dict has keys: coverage_zone,
1836
+ base_station_mac, base_station_model. When provided, stations
1837
+ whose coverage_zone matches the network's device pool name are
1838
+ attached to the CanonicalDECTNetwork.base_stations list.
1839
+
1840
+ Returns:
1841
+ List of CanonicalDECTNetwork objects (also written to the store).
1842
+ """
1843
+ # Build coverage_zone → base station list index for fast lookup
1844
+ inventory_by_zone: dict[str, list[dict[str, str]]] = {}
1845
+ for entry in (dect_inventory or []):
1846
+ zone = entry.get("coverage_zone", "")
1847
+ if zone:
1848
+ inventory_by_zone.setdefault(zone, []).append(entry)
1849
+
1850
+ # Collect all DECT-tier devices
1851
+ dect_by_pool: dict[str, list[CanonicalDevice]] = {}
1852
+ for obj in store.query_by_type("device"):
1853
+ if not isinstance(obj, CanonicalDevice):
1854
+ continue
1855
+ if obj.compatibility_tier != DeviceCompatibilityTier.DECT:
1856
+ continue
1857
+ pool_name = (obj.pre_migration_state or {}).get("cucm_device_pool") or "DEFAULT"
1858
+ dect_by_pool.setdefault(pool_name, []).append(obj)
1859
+
1860
+ networks: list[CanonicalDECTNetwork] = []
1861
+ for pool_name, devices in dect_by_pool.items():
1862
+ network_name = f"DECT-{pool_name}"
1863
+ handset_assignments = [
1864
+ {"device_canonical_id": d.canonical_id, "cucm_device_name": d.cucm_device_name}
1865
+ for d in devices
1866
+ ]
1867
+
1868
+ # Attach base stations from inventory whose coverage_zone matches this pool
1869
+ base_stations: list[dict] = []
1870
+ for entry in inventory_by_zone.get(pool_name, []):
1871
+ base_stations.append({
1872
+ "mac": entry["base_station_mac"],
1873
+ "model": entry["base_station_model"],
1874
+ "coverage_zone": entry["coverage_zone"],
1875
+ })
1876
+
1877
+ # Derive DECT network model from inventory (first station's model; PENDING if absent)
1878
+ network_model = base_stations[0]["model"] if base_stations else "PENDING"
1879
+
1880
+ network = CanonicalDECTNetwork(
1881
+ canonical_id=f"dect_network:{pool_name}",
1882
+ provenance=Provenance(
1883
+ source_system="cucm",
1884
+ source_id=pool_name,
1885
+ source_name=network_name,
1886
+ cluster=devices[0].provenance.cluster if devices[0].provenance else "default",
1887
+ extracted_at=_now(),
1888
+ ),
1889
+ status=MigrationStatus.NORMALIZED,
1890
+ network_name=network_name,
1891
+ display_name=network_name,
1892
+ model=network_model,
1893
+ access_code="",
1894
+ base_stations=base_stations,
1895
+ handset_assignments=handset_assignments,
1896
+ pre_migration_state={
1897
+ "cucm_device_pool": pool_name,
1898
+ "handset_count": len(devices),
1899
+ "base_station_count": len(base_stations),
1900
+ },
1901
+ )
1902
+ store.upsert_object(network)
1903
+ networks.append(network)
1904
+
1905
+ return networks
1906
+
1907
+
1908
+ # ---------------------------------------------------------------------------
1909
+ # Registry: maps extractor object types to normalizer functions
1910
+ # ---------------------------------------------------------------------------
1911
+
1912
+ NORMALIZER_REGISTRY: dict[str, callable] = {
1913
+ "device_pool": normalize_device_pool,
1914
+ "datetime_group": normalize_datetime_group,
1915
+ "user": normalize_user,
1916
+ "phone": normalize_phone,
1917
+ "workspace": normalize_workspace,
1918
+ "css": normalize_css,
1919
+ "partition": normalize_partition,
1920
+ "route_pattern": normalize_route_pattern,
1921
+ "translation_pattern": normalize_translation_pattern,
1922
+ "hunt_pilot": normalize_hunt_pilot,
1923
+ "hunt_list": normalize_hunt_list,
1924
+ "line_group": normalize_line_group,
1925
+ "cti_route_point": normalize_cti_route_point,
1926
+ "call_park": normalize_call_park,
1927
+ "pickup_group": normalize_pickup_group,
1928
+ "time_schedule": normalize_time_schedule,
1929
+ "time_period": normalize_time_period,
1930
+ "gateway": normalize_gateway,
1931
+ "sip_trunk": normalize_sip_trunk,
1932
+ "route_group": normalize_route_group,
1933
+ "route_list": normalize_route_list,
1934
+ "voicemail_profile": normalize_voicemail_profile,
1935
+ "voicemail_pilot": normalize_voicemail_pilot,
1936
+ "voicemail_group": normalize_voicemail_group,
1937
+ "cucm_location": normalize_cucm_location,
1938
+ "button_template": normalize_button_template,
1939
+ "softkey_template": normalize_softkey_template,
1940
+ "remote_destination": normalize_remote_destination,
1941
+ "recording_profile": normalize_recording_profile,
1942
+ "calling_party_xform": normalize_calling_party_xform,
1943
+ "called_party_xform": normalize_called_party_xform,
1944
+ "info_device_profile": normalize_info_device_profile,
1945
+ "intercept_candidate": normalize_intercept_candidate,
1946
+ "elin_group": normalize_elin_group,
1947
+ "geo_location": normalize_geo_location,
1948
+ "device_profile": normalize_device_profile,
1949
+ "moh_source": normalize_moh_source,
1950
+ "announcement": normalize_announcement,
1951
+ "executive_assistant_pair": normalize_executive_assistant_pair,
1952
+ "executive_settings": normalize_executive_settings,
1953
+ **{f"info_{s}": _make_info_normalizer(s) for s in _INFO_SUFFIXES},
1954
+ }
1955
+
1956
+
1957
+ # ---------------------------------------------------------------------------
1958
+ # DiscoveryResult.raw_data sub-key → normalizer mapping
1959
+ # Maps each raw_data path to the normalizer function to call on each item.
1960
+ # (from Phase 03 DiscoveryResult docstring)
1961
+ # ---------------------------------------------------------------------------
1962
+
1963
+ RAW_DATA_MAPPING: list[tuple[str, str, str]] = [
1964
+ # (extractor_key, sub_key, normalizer_registry_key)
1965
+ ("locations", "device_pools", "device_pool"),
1966
+ ("locations", "datetime_groups", "datetime_group"),
1967
+ ("locations", "cucm_locations", "cucm_location"),
1968
+ ("users", "users", "user"),
1969
+ ("devices", "phones", "phone"),
1970
+ # workspace classification happens after phone normalization (see normalize_discovery)
1971
+ ("routing", "partitions", "partition"),
1972
+ ("routing", "css_list", "css"),
1973
+ ("routing", "route_patterns", "route_pattern"),
1974
+ ("routing", "gateways", "gateway"),
1975
+ ("routing", "sip_trunks", "sip_trunk"),
1976
+ ("routing", "route_groups", "route_group"),
1977
+ ("routing", "route_lists", "route_list"),
1978
+ ("routing", "translation_patterns", "translation_pattern"),
1979
+ ("features", "hunt_pilots", "hunt_pilot"),
1980
+ ("features", "hunt_lists", "hunt_list"),
1981
+ ("features", "line_groups", "line_group"),
1982
+ ("features", "cti_route_points", "cti_route_point"),
1983
+ ("features", "call_parks", "call_park"),
1984
+ ("features", "pickup_groups", "pickup_group"),
1985
+ ("features", "time_schedules", "time_schedule"),
1986
+ ("features", "time_periods", "time_period"),
1987
+ ("voicemail", "voicemail_profiles", "voicemail_profile"),
1988
+ ("voicemail", "voicemail_pilots", "voicemail_pilot"),
1989
+ ("voicemail", "shared_mailboxes", "voicemail_group"),
1990
+ ("templates", "button_templates", "button_template"),
1991
+ ("templates", "softkey_templates", "softkey_template"),
1992
+ ("remote_destinations", "remote_destinations", "remote_destination"),
1993
+ ("tier4", "recording_profiles", "recording_profile"),
1994
+ ("tier4", "remote_destination_profiles", "remote_destination"), # reuses existing normalizer
1995
+ ("tier4", "calling_party_transformations", "calling_party_xform"),
1996
+ ("tier4", "called_party_transformations", "called_party_xform"),
1997
+ ("tier4", "device_profiles", "info_device_profile"),
1998
+ ("tier4", "intercept_candidates", "intercept_candidate"),
1999
+ ("e911", "elin_groups", "elin_group"),
2000
+ ("e911", "geo_locations", "geo_location"),
2001
+ ("device_profiles", "device_profiles", "device_profile"),
2002
+ ("moh", "moh_sources", "moh_source"),
2003
+ ("announcements", "announcements", "announcement"),
2004
+ ("features", "executive_assistant_pairs", "executive_assistant_pair"),
2005
+ ("features", "executive_settings", "executive_settings"),
2006
+ # Tier 3: Informational types (all under "informational" extractor key)
2007
+ *[("informational", suffix, f"info_{suffix}") for suffix in _INFO_SUFFIXES],
2008
+ ]