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,1179 @@
1
+ """Feature mapper: CUCM call features -> Webex Calling features.
2
+
3
+ Maps CUCM Hunt Pilots (+ Hunt Lists + Line Groups), CTI Route Points,
4
+ Call Park Numbers, Pickup Groups, Paging Groups, and Time Schedules to
5
+ their Webex Calling equivalents.
6
+
7
+ (from 03b-transform-mappers.md §8, feature_mapper)
8
+
9
+ Cross-ref reads:
10
+ hunt_pilot_has_hunt_list (#21) — HuntPilot -> HuntList
11
+ hunt_list_has_line_group (#22) — HuntList -> LineGroup
12
+ line_group_has_members (#23) — LineGroup -> DN/Line (agent resolution)
13
+ cti_rp_has_script (#24) — CTI Route Point -> Script (AA approximation)
14
+ schedule_has_time_period (#25) — Schedule -> TimePeriod (OperatingMode)
15
+
16
+ Decisions generated:
17
+ FEATURE_APPROXIMATION — CTI RP with complex script, agent limit exceeded
18
+ MISSING_DATA — unresolvable hunt pilot DN
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any
25
+
26
+ from wxcli.migration.models import (
27
+ CanonicalAutoAttendant,
28
+ CanonicalCallPark,
29
+ CanonicalCallQueue,
30
+ CanonicalHuntGroup,
31
+ CanonicalLocationSchedule,
32
+ CanonicalOperatingMode,
33
+ CanonicalPagingGroup,
34
+ CanonicalPickupGroup,
35
+ DecisionType,
36
+ MapperResult,
37
+ MigrationStatus,
38
+ )
39
+ from wxcli.migration.store import MigrationStore
40
+ from wxcli.migration.transform.mappers.base import (
41
+ Mapper,
42
+ accept_option,
43
+ decision_to_store_dict,
44
+ extract_provenance,
45
+ hash_id,
46
+ manual_option,
47
+ skip_option,
48
+ )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Shared helper: resolve DN canonical_id → owning user/workspace canonical_id
55
+ # (from 03b-transform-mappers.md §8: "each line group member DN → resolve to
56
+ # Webex person/workspace/virtual line ID")
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def _resolve_dn_to_owner(store: MigrationStore, dn_canonical_id: str) -> str | None:
60
+ """Resolve a DN canonical_id to its owning user or workspace.
61
+
62
+ Chain: DN ← device_has_dn ← device → device_owned_by_user → user
63
+ If no user owner found, check for common-area device → workspace.
64
+ """
65
+ # Find devices that have this DN (reverse lookup: to_id = dn_id)
66
+ device_refs = store.get_cross_refs(to_id=dn_canonical_id, relationship="device_has_dn")
67
+ for ref in device_refs:
68
+ device_id = ref["from_id"]
69
+
70
+ # Check if device is owned by a user
71
+ user_refs = store.find_cross_refs(device_id, "device_owned_by_user")
72
+ if user_refs:
73
+ return user_refs[0] # Return canonical user ID
74
+
75
+ # Check if device is a common-area device → workspace
76
+ # Common-area devices don't have device_owned_by_user; the device itself
77
+ # may be associated with a workspace object
78
+ ws_refs = store.get_cross_refs(to_id=device_id, relationship="workspace_has_device")
79
+ if ws_refs:
80
+ return ws_refs[0]["from_id"]
81
+
82
+ return None
83
+
84
+
85
+ def _resolve_agents_from_dns(
86
+ store: MigrationStore, dn_ids: list[str]
87
+ ) -> list[str]:
88
+ """Resolve a list of DN canonical_ids to deduplicated owner canonical_ids.
89
+
90
+ Falls back to the raw DN canonical_id when the device→user chain isn't
91
+ available (e.g., cross-refs not yet built). This preserves backward
92
+ compatibility — the command builder handles both user IDs and DN IDs.
93
+ """
94
+ seen: set[str] = set()
95
+ resolved: list[str] = []
96
+ for dn_id in dn_ids:
97
+ owner = _resolve_dn_to_owner(store, dn_id)
98
+ agent_id = owner if owner else dn_id
99
+ if agent_id not in seen:
100
+ seen.add(agent_id)
101
+ resolved.append(agent_id)
102
+ return resolved
103
+
104
+
105
+ def _resolve_feature_location(
106
+ store: MigrationStore,
107
+ agents: list[str],
108
+ provenance: dict | None = None,
109
+ ) -> str | None:
110
+ """Determine a feature's location from its agents.
111
+
112
+ 1. Read each agent's location_id from the store
113
+ 2. If all agents share the same location → use it
114
+ 3. If agents span multiple locations → majority wins
115
+ 4. If no agents have location_id → return None
116
+
117
+ Note: Features without agents (e.g., call parks) get location_id from
118
+ their pre_migration_state directly. AAs and schedules are location-scoped
119
+ at creation time via the command builder's LOCATION_ID placeholder.
120
+ """
121
+ if not agents:
122
+ return None
123
+
124
+ location_counts: dict[str, int] = {}
125
+ for agent_cid in agents:
126
+ agent_obj = store.get_object(agent_cid)
127
+ if agent_obj:
128
+ loc = agent_obj.get("location_id")
129
+ if loc:
130
+ location_counts[loc] = location_counts.get(loc, 0) + 1
131
+
132
+ if location_counts:
133
+ # Return the most common location
134
+ return max(location_counts, key=location_counts.get) # type: ignore[arg-type]
135
+
136
+ return None
137
+
138
+
139
+ # Agent limits by Webex policy
140
+ # (from 03b-transform-mappers.md §8: "SIMULTANEOUS max 50, WEIGHTED max 100, others max 1000")
141
+ _AGENT_LIMITS: dict[str, int] = {
142
+ "SIMULTANEOUS": 50,
143
+ "WEIGHTED": 100,
144
+ "REGULAR": 1000,
145
+ "CIRCULAR": 1000,
146
+ "UNIFORM": 1000,
147
+ }
148
+
149
+ # Paging group target limit
150
+ # (from 03b-transform-mappers.md §8: "Webex paging groups support up to 75 targets")
151
+ _MAX_PAGING_TARGETS = 75
152
+
153
+ # Algorithm mapping: CUCM hunt algorithm -> Webex policy
154
+ # (from 03b-transform-mappers.md §10: Algorithm Mapping Table)
155
+ _ALGORITHM_TO_POLICY: dict[str, str] = {
156
+ "Top Down": "REGULAR",
157
+ "Circular": "CIRCULAR",
158
+ "Longest Idle Time": "UNIFORM",
159
+ "Broadcast": "SIMULTANEOUS",
160
+ }
161
+
162
+
163
+ class FeatureMapper(Mapper):
164
+ """Maps CUCM call features to Webex Calling feature objects.
165
+
166
+ Produces: CanonicalHuntGroup, CanonicalCallQueue, CanonicalAutoAttendant,
167
+ CanonicalCallPark, CanonicalPickupGroup, CanonicalPagingGroup,
168
+ CanonicalOperatingMode.
169
+
170
+ (from 03b-transform-mappers.md §8)
171
+ """
172
+
173
+ name = "feature_mapper"
174
+ depends_on = ["location_mapper", "line_mapper", "user_mapper"]
175
+
176
+ def map(self, store: MigrationStore) -> MapperResult:
177
+ """Read CUCM feature objects and produce Webex feature canonical objects."""
178
+ result = MapperResult()
179
+
180
+ # Disable FK checks for cross-ref writing — feature_has_agent refs
181
+ # may point to agents (DN IDs) not in the objects table.
182
+ store.conn.execute("PRAGMA foreign_keys=OFF")
183
+ try:
184
+ self._map_hunt_pilots(store, result)
185
+ self._map_cti_route_points(store, result)
186
+ self._map_call_parks(store, result)
187
+ self._map_pickup_groups(store, result)
188
+ self._map_paging_groups(store, result)
189
+ self._map_schedules(store, result)
190
+ self._map_location_schedules(store, result)
191
+ finally:
192
+ store.conn.execute("PRAGMA foreign_keys=ON")
193
+
194
+ return result
195
+
196
+ # ------------------------------------------------------------------
197
+ # Hunt Pilots: classify -> HG or CQ
198
+ # (from 03b-transform-mappers.md §10: classify_hunt_pilot algorithm)
199
+ # ------------------------------------------------------------------
200
+
201
+ def _map_hunt_pilots(self, store: MigrationStore, result: MapperResult) -> None:
202
+ """Map CUCM hunt pilots to Hunt Groups or Call Queues.
203
+
204
+ Traverses 3-object chain: hunt_pilot -> hunt_list -> line_group -> members
205
+ """
206
+ # Hoist voicemail_group presence check out of the per-hunt-pilot loop —
207
+ # the answer can't change inside the loop (nothing writes voicemail_group
208
+ # here), so computing it once avoids O(N*M) store queries.
209
+ has_vm_group = any(True for _ in store.get_objects("voicemail_group"))
210
+
211
+ # Index voicemail groups by extension for O(1) lookup — used to write
212
+ # feature_forwards_to_voicemail_group cross-refs so the dependency
213
+ # graph can enforce voicemail_group:create → feature:configure_*.
214
+ vg_by_ext: dict[str, str] = {}
215
+ for vg in store.get_objects("voicemail_group"):
216
+ vg_state = vg.get("pre_migration_state") or {}
217
+ vg_ext = vg_state.get("extension")
218
+ if vg_ext:
219
+ vg_by_ext[str(vg_ext)] = vg["canonical_id"]
220
+
221
+ for hp_data in store.get_objects("hunt_pilot"):
222
+ hp_id = hp_data["canonical_id"]
223
+ hp_state = hp_data.get("pre_migration_state") or {}
224
+
225
+ # --- Resolve hunt list via cross-ref (#21) ---
226
+ hunt_list_refs = store.find_cross_refs(hp_id, "hunt_pilot_has_hunt_list")
227
+ hunt_list_data = None
228
+ if hunt_list_refs:
229
+ hunt_list_data = store.get_object(hunt_list_refs[0])
230
+
231
+ hunt_list_state = (hunt_list_data or {}).get("pre_migration_state") or {}
232
+
233
+ # --- Resolve line groups via hunt_list -> line_group chain (#22) ---
234
+ line_group_ids: list[str] = []
235
+ if hunt_list_refs:
236
+ for hl_ref in hunt_list_refs:
237
+ lg_refs = store.find_cross_refs(hl_ref, "hunt_list_has_line_group")
238
+ line_group_ids.extend(lg_refs)
239
+
240
+ # --- Resolve members from all line groups (#23) ---
241
+ member_ids: list[str] = []
242
+ for lg_id in line_group_ids:
243
+ member_refs = store.find_cross_refs(lg_id, "line_group_has_members")
244
+ member_ids.extend(member_refs)
245
+
246
+ # Resolve DN references to canonical user/workspace IDs
247
+ # (Fix 1: DN agents → canonical user IDs via device chain)
248
+ unique_members = _resolve_agents_from_dns(store, member_ids)
249
+
250
+ # --- Load first line group state for algorithm + rna fields ---
251
+ first_lg_state: dict[str, Any] = {}
252
+ if line_group_ids:
253
+ first_lg_data = store.get_object(line_group_ids[0])
254
+ if first_lg_data:
255
+ first_lg_state = first_lg_data.get("pre_migration_state") or {}
256
+
257
+ # --- Classify hunt pilot ---
258
+ classification, policy = self._classify_hunt_pilot(
259
+ hp_state, hunt_list_state, first_lg_state
260
+ )
261
+
262
+ # --- Phase A: voicemail-on-overflow gap detection ---
263
+ # If the hunt list forwards to voicemail but we have no matching
264
+ # voicemail_group object in the store, the shared mailbox was
265
+ # not extracted from Unity Connection. Surface this as a
266
+ # MISSING_DATA decision so the assessment report flags it.
267
+ # (from docs/superpowers/specs/2026-04-10-voicemail-groups.md Phase A)
268
+ vm_usage = hunt_list_state.get("voice_mail_usage", "NONE")
269
+ if vm_usage and vm_usage != "NONE":
270
+ hl_name = hunt_list_state.get("hunt_list_name") or ""
271
+ # Use the already-resolved cross-ref canonical_id instead of
272
+ # recomputing it from the hunt_list_name string — avoids silent
273
+ # breakage if the canonical_id format ever changes.
274
+ hl_id = hunt_list_refs[0] if hunt_list_refs else ""
275
+ if not has_vm_group:
276
+ gap_decision = self._create_decision(
277
+ store=store,
278
+ decision_type=DecisionType.MISSING_DATA,
279
+ severity="MEDIUM",
280
+ summary=(
281
+ f"Hunt list '{hl_name}' forwards unanswered/overflow "
282
+ f"calls to a shared voicemail mailbox "
283
+ f"({vm_usage}) — the mailbox configuration was "
284
+ f"not extracted from Unity Connection and will "
285
+ f"not be migrated automatically"
286
+ ),
287
+ context={
288
+ "hunt_list_id": hl_id,
289
+ "hunt_pilot_id": hp_id,
290
+ "voice_mail_usage": vm_usage,
291
+ "reason": "shared_voicemail_not_extracted",
292
+ },
293
+ options=[
294
+ accept_option(
295
+ "Accept loss — rebuild voicemail group manually "
296
+ "in Webex post-migration"
297
+ ),
298
+ manual_option(
299
+ "Provide Unity Connection credentials and "
300
+ "re-run discovery to extract shared mailboxes"
301
+ ),
302
+ ],
303
+ affected_objects=[hp_id] + ([hl_id] if hl_id else []),
304
+ )
305
+ store.save_decision(decision_to_store_dict(gap_decision))
306
+ result.decisions.append(gap_decision)
307
+
308
+ # --- Extract common fields ---
309
+ name = (
310
+ hp_state.get("name")
311
+ or hp_state.get("description")
312
+ or hp_state.get("pattern")
313
+ or hp_id.split(":", 1)[-1]
314
+ )
315
+ extension = hp_state.get("extension") or hp_state.get("pattern")
316
+ phone_number = hp_state.get("phone_number")
317
+
318
+ # --- Ring no-answer ---
319
+ # rnaReversionTimeOut is on LineGroup, not HuntPilot
320
+ # (from 02b-cucm-extraction.md §2.5: rnaReversionTimeOut on LineGroup)
321
+ rna_timeout = first_lg_state.get("rnaReversionTimeOut")
322
+ if rna_timeout is None:
323
+ # Fallback: check other line groups
324
+ for lg_id in line_group_ids:
325
+ lg_data = store.get_object(lg_id)
326
+ if lg_data:
327
+ lg_state = lg_data.get("pre_migration_state") or {}
328
+ rna_timeout = lg_state.get("rnaReversionTimeOut")
329
+ if rna_timeout is not None:
330
+ break
331
+
332
+ no_answer_rings = None
333
+ if rna_timeout is not None:
334
+ try:
335
+ no_answer_rings = max(1, int(rna_timeout) // 6)
336
+ except (ValueError, TypeError):
337
+ pass
338
+
339
+ # --- Check agent limits ---
340
+ agent_limit = _AGENT_LIMITS.get(policy, 1000)
341
+ if len(unique_members) > agent_limit:
342
+ decision = self._create_decision(
343
+ store=store,
344
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
345
+ severity="HIGH",
346
+ summary=(
347
+ f"Hunt pilot '{name}' has {len(unique_members)} agents "
348
+ f"but {policy} policy allows max {agent_limit}"
349
+ ),
350
+ context={
351
+ "hunt_pilot_id": hp_id,
352
+ "name": name,
353
+ "policy": policy,
354
+ "agent_count": len(unique_members),
355
+ "agent_limit": agent_limit,
356
+ "reason": "agent_limit_exceeded",
357
+ },
358
+ options=[
359
+ accept_option(
360
+ f"Truncate to first {agent_limit} agents"
361
+ ),
362
+ manual_option(
363
+ "Admin splits into multiple groups manually"
364
+ ),
365
+ skip_option("Feature not migrated"),
366
+ ],
367
+ affected_objects=[hp_id],
368
+ )
369
+ store.save_decision(decision_to_store_dict(decision))
370
+ result.decisions.append(decision)
371
+
372
+ provenance = extract_provenance(hp_data)
373
+
374
+ # Fix 3: Resolve feature location from agents
375
+ location_id = _resolve_feature_location(
376
+ store, unique_members, hp_data.get("provenance")
377
+ )
378
+
379
+ # --- Extract forwarding destinations (top-level on hunt pilot) ---
380
+ fwd_no_answer = hp_state.get("forwardHuntNoAnswer") or {}
381
+ fwd_busy = hp_state.get("forwardHuntBusy") or {}
382
+ forward_no_answer_dest = fwd_no_answer.get("destination") or None
383
+ forward_busy_dest = fwd_busy.get("destination") or None
384
+
385
+ # --- Extract queue overflow destinations (nested in queueCalls) ---
386
+ queue_calls_for_overflow = hp_state.get("queueCalls") or {}
387
+ queue_full_dest = queue_calls_for_overflow.get("queueFullDestination") or None
388
+ max_wait_dest = queue_calls_for_overflow.get("maxWaitTimeDestination") or None
389
+ no_agent_dest = queue_calls_for_overflow.get("noAgentDestination") or None
390
+ max_wait_time_val = queue_calls_for_overflow.get("maxWaitTime")
391
+ if max_wait_time_val is not None:
392
+ try:
393
+ max_wait_time_val = int(max_wait_time_val)
394
+ except (ValueError, TypeError):
395
+ max_wait_time_val = None
396
+
397
+ if classification == "CALL_QUEUE":
398
+ # --- Produce CanonicalCallQueue ---
399
+ # maxCallersInQueue is nested in queueCalls (XCallsQueue)
400
+ # (from 02b-cucm-extraction.md §6: queueCalls complex type)
401
+ queue_calls = hp_state.get("queueCalls") or {}
402
+ queue_size = queue_calls.get("maxCallersInQueue") or 25
403
+ try:
404
+ queue_size = int(queue_size)
405
+ except (ValueError, TypeError):
406
+ queue_size = 25
407
+
408
+ feature_cid = f"call_queue:{hash_id(hp_id)}"
409
+ cq = CanonicalCallQueue(
410
+ canonical_id=feature_cid,
411
+ provenance=provenance,
412
+ status=MigrationStatus.ANALYZED,
413
+ name=name,
414
+ extension=extension,
415
+ phone_number=phone_number,
416
+ policy=policy,
417
+ routing_type="PRIORITY_BASED",
418
+ agents=unique_members,
419
+ queue_size=queue_size,
420
+ enabled=hp_state.get("enabled", True),
421
+ location_id=location_id,
422
+ forward_always_enabled=False,
423
+ forward_always_destination=None,
424
+ queue_full_destination=queue_full_dest,
425
+ max_wait_time_destination=max_wait_dest,
426
+ max_wait_time=max_wait_time_val,
427
+ no_agent_destination=no_agent_dest,
428
+ )
429
+ store.upsert_object(cq)
430
+ result.objects_created += 1
431
+
432
+ # Fix 8: Write feature_has_agent cross-refs
433
+ for agent_cid in unique_members:
434
+ store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
435
+
436
+ # Phase C: link call queue to voicemail group for overflow dependency
437
+ for dest in [queue_full_dest, max_wait_dest, no_agent_dest]:
438
+ if dest and str(dest) in vg_by_ext:
439
+ store.add_cross_ref(
440
+ feature_cid, vg_by_ext[str(dest)],
441
+ "feature_forwards_to_voicemail_group",
442
+ )
443
+
444
+ else:
445
+ # --- Produce CanonicalHuntGroup ---
446
+ feature_cid = f"hunt_group:{hash_id(hp_id)}"
447
+ hg = CanonicalHuntGroup(
448
+ canonical_id=feature_cid,
449
+ provenance=provenance,
450
+ status=MigrationStatus.ANALYZED,
451
+ name=name,
452
+ extension=extension,
453
+ phone_number=phone_number,
454
+ policy=policy,
455
+ agents=unique_members,
456
+ no_answer_rings=no_answer_rings,
457
+ enabled=hp_state.get("enabled", True),
458
+ location_id=location_id,
459
+ forward_no_answer_enabled=bool(forward_no_answer_dest),
460
+ forward_no_answer_destination=forward_no_answer_dest,
461
+ forward_busy_enabled=bool(forward_busy_dest),
462
+ forward_busy_destination=forward_busy_dest,
463
+ )
464
+ store.upsert_object(hg)
465
+ result.objects_created += 1
466
+
467
+ # Fix 8: Write feature_has_agent cross-refs
468
+ for agent_cid in unique_members:
469
+ store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
470
+
471
+ # Phase C: link hunt group to voicemail group for forwarding dependency
472
+ for dest in [forward_no_answer_dest, forward_busy_dest]:
473
+ if dest and str(dest) in vg_by_ext:
474
+ store.add_cross_ref(
475
+ feature_cid, vg_by_ext[str(dest)],
476
+ "feature_forwards_to_voicemail_group",
477
+ )
478
+
479
+ # --- MISSING_DATA if no extension ---
480
+ if not extension:
481
+ decision = self._create_decision(
482
+ store=store,
483
+ decision_type=DecisionType.MISSING_DATA,
484
+ severity="HIGH",
485
+ summary=(
486
+ f"Hunt pilot '{name}' has no resolvable extension"
487
+ ),
488
+ context={
489
+ "hunt_pilot_id": hp_id,
490
+ "name": name,
491
+ "reason": "no_extension",
492
+ },
493
+ options=[
494
+ manual_option("Admin provides extension"),
495
+ skip_option("Feature not migrated"),
496
+ ],
497
+ affected_objects=[hp_id],
498
+ )
499
+ store.save_decision(decision_to_store_dict(decision))
500
+ result.decisions.append(decision)
501
+
502
+ def _classify_hunt_pilot(
503
+ self,
504
+ hp_state: dict[str, Any],
505
+ hunt_list_state: dict[str, Any],
506
+ line_group_state: dict[str, Any],
507
+ ) -> tuple[str, str]:
508
+ """Determine Webex object type and policy for a CUCM hunt pilot.
509
+
510
+ Returns: (classification, policy) where classification is
511
+ 'HUNT_GROUP' or 'CALL_QUEUE', and policy is the Webex callPolicies.policy value.
512
+
513
+ (from 03b-transform-mappers.md §10: classify_hunt_pilot algorithm)
514
+ """
515
+ # Step 1: isCtiRp check is handled separately in _map_cti_route_points
516
+ # This method only handles actual hunt pilots.
517
+
518
+ # Step 2: Check for queue-style indicators
519
+ # All queue fields are nested in queueCalls (XCallsQueue) on HuntPilot.
520
+ # queueCalls is None when queuing is disabled.
521
+ # voiceMailUsage is on HuntList (from 02b-cucm-extraction.md §6).
522
+ # (from 02b-cucm-extraction.md §6: queueCalls is complex type XCallsQueue)
523
+ queue_calls = hp_state.get("queueCalls") or {}
524
+ has_queue_features = any([
525
+ queue_calls.get("enabled", False),
526
+ (queue_calls.get("maxCallersInQueue") or 0) > 0,
527
+ queue_calls.get("networkHoldMohAudioSourceID") is not None,
528
+ queue_calls.get("queueFullDestination") is not None
529
+ or queue_calls.get("maxWaitTimeDestination") is not None
530
+ or queue_calls.get("noAgentDestination") is not None,
531
+ hunt_list_state.get("voiceMailUsage", "NONE") != "NONE",
532
+ ])
533
+
534
+ # Step 3: Map distribution algorithm to Webex policy
535
+ # distributionAlgorithm is on LineGroup, not HuntList
536
+ # (from 02b-cucm-extraction.md §2.5: distributionAlgorithm on LineGroup)
537
+ algorithm = line_group_state.get("distributionAlgorithm", "")
538
+ policy = _ALGORITHM_TO_POLICY.get(algorithm, "CIRCULAR")
539
+
540
+ if has_queue_features:
541
+ return "CALL_QUEUE", policy
542
+ return "HUNT_GROUP", policy
543
+
544
+ # ------------------------------------------------------------------
545
+ # CTI Route Points -> Auto Attendant
546
+ # (from 03b-transform-mappers.md §8: AA field mapping)
547
+ # ------------------------------------------------------------------
548
+
549
+ def _map_cti_route_points(
550
+ self, store: MigrationStore, result: MapperResult
551
+ ) -> None:
552
+ """Map CTI Route Points to CanonicalAutoAttendant objects.
553
+
554
+ Always produces a FEATURE_APPROXIMATION decision because IVR scripts
555
+ can't be fully parsed into simple AA menu keys.
556
+
557
+ (from 03b-transform-mappers.md §8: CTI RP -> AA mapping)
558
+ """
559
+ for cti_data in store.get_objects("cti_rp"):
560
+ cti_id = cti_data["canonical_id"]
561
+ cti_state = cti_data.get("pre_migration_state") or {}
562
+
563
+ name = (
564
+ cti_state.get("name")
565
+ or cti_state.get("description")
566
+ or cti_id.split(":", 1)[-1]
567
+ )
568
+ extension = cti_state.get("extension") or cti_state.get("pattern")
569
+
570
+ # --- Check for script reference (#24) ---
571
+ script_refs = store.find_cross_refs(cti_id, "cti_rp_has_script")
572
+ has_script = bool(script_refs)
573
+
574
+ # --- Resolve schedule via CSS → partition → timeScheduleIdName ---
575
+ # CTI RP has NO schedule field. Time-based routing uses partition
576
+ # timeScheduleIdName. Resolve: CTI RP callingSearchSpaceName → CSS →
577
+ # partitions → partition's timeScheduleIdName → schedule
578
+ # (from 02b-cucm-extraction.md §2.5: CTI RP has no schedule field)
579
+ business_schedule = None
580
+ css_name = cti_state.get("callingSearchSpaceName")
581
+ if css_name:
582
+ css_id = f"css:{css_name}"
583
+ partition_refs = store.get_cross_refs(
584
+ from_id=css_id, relationship="css_contains_partition"
585
+ )
586
+ for pt_ref in sorted(partition_refs, key=lambda r: r.get("ordinal") or 0):
587
+ pt_data = store.get_object(pt_ref["to_id"])
588
+ if pt_data:
589
+ pt_state = pt_data.get("pre_migration_state") or {}
590
+ sched_name = pt_state.get("timeScheduleIdName")
591
+ if sched_name:
592
+ business_schedule = sched_name
593
+ break
594
+
595
+ # --- Default AA menus ---
596
+ # (from 03b-transform-mappers.md §8: "AA requires both menus")
597
+ business_hours_menu = {
598
+ "greeting": "DEFAULT",
599
+ "extensionEnabled": True,
600
+ "keyConfigurations": [
601
+ {"key": "0", "action": "TRANSFER_TO_OPERATOR", "value": "0"},
602
+ ],
603
+ }
604
+ after_hours_menu = {
605
+ "greeting": "DEFAULT",
606
+ "extensionEnabled": True,
607
+ "keyConfigurations": [
608
+ {"key": "0", "action": "TRANSFER_TO_OPERATOR", "value": "0"},
609
+ ],
610
+ }
611
+
612
+ # Extract call-forward-all destination from CTI Route Point
613
+ # (CUCM CallForwardAll is the closest analogue to a Webex AA always-forward)
614
+ cfa = cti_state.get("callForwardAll") or {}
615
+ cfa_destination = cfa.get("destination") or None
616
+
617
+ aa = CanonicalAutoAttendant(
618
+ canonical_id=f"auto_attendant:{hash_id(cti_id)}",
619
+ provenance=extract_provenance(cti_data),
620
+ status=MigrationStatus.ANALYZED,
621
+ name=name,
622
+ extension=extension,
623
+ business_schedule=business_schedule,
624
+ business_hours_menu=business_hours_menu,
625
+ after_hours_menu=after_hours_menu,
626
+ forward_always_enabled=bool(cfa_destination),
627
+ forward_always_destination=cfa_destination,
628
+ )
629
+ store.upsert_object(aa)
630
+ result.objects_created += 1
631
+
632
+ # --- FEATURE_APPROXIMATION decision ---
633
+ # (from 03b-transform-mappers.md §8: CTI RP always gets this decision)
634
+ script_detail = "with script reference" if has_script else "without script"
635
+ decision = self._create_decision(
636
+ store=store,
637
+ decision_type=DecisionType.FEATURE_APPROXIMATION,
638
+ severity="HIGH",
639
+ summary=(
640
+ f"CTI Route Point '{name}' ({script_detail}) mapped to basic "
641
+ f"Auto Attendant — IVR logic requires manual configuration"
642
+ ),
643
+ context={
644
+ "cti_rp_id": cti_id,
645
+ "name": name,
646
+ "classification": "AUTO_ATTENDANT",
647
+ "complex_script": has_script,
648
+ "has_script": has_script,
649
+ "reason": "cti_rp_to_auto_attendant",
650
+ },
651
+ options=[
652
+ accept_option(
653
+ "Create basic AA with default menus; configure manually"
654
+ ),
655
+ skip_option("Skip — handle AA creation manually post-migration"),
656
+ manual_option(
657
+ "Map to HG/CQ if routing is simple enough"
658
+ ),
659
+ ],
660
+ affected_objects=[cti_id],
661
+ )
662
+ store.save_decision(decision_to_store_dict(decision))
663
+ result.decisions.append(decision)
664
+
665
+ # ------------------------------------------------------------------
666
+ # Call Park: CUCM Call Park Number -> CanonicalCallPark
667
+ # (from 03b-transform-mappers.md §8: simple features)
668
+ # ------------------------------------------------------------------
669
+
670
+ def _map_call_parks(self, store: MigrationStore, result: MapperResult) -> None:
671
+ """Map CUCM Call Park numbers to CanonicalCallPark objects."""
672
+ for cp_data in store.get_objects("call_park"):
673
+ cp_id = cp_data["canonical_id"]
674
+ cp_state = cp_data.get("pre_migration_state") or {}
675
+
676
+ name = (
677
+ cp_state.get("name")
678
+ or cp_state.get("description")
679
+ or cp_id.split(":", 1)[-1]
680
+ )
681
+ extension = cp_state.get("extension") or cp_state.get("pattern")
682
+ location_id = cp_state.get("location_id")
683
+
684
+ cp = CanonicalCallPark(
685
+ canonical_id=f"call_park_ext:{hash_id(cp_id)}",
686
+ provenance=extract_provenance(cp_data),
687
+ status=MigrationStatus.ANALYZED,
688
+ name=name,
689
+ extension=extension,
690
+ location_id=location_id,
691
+ )
692
+ store.upsert_object(cp)
693
+ result.objects_created += 1
694
+
695
+ # ------------------------------------------------------------------
696
+ # Pickup Group: CUCM Pickup Group -> CanonicalPickupGroup
697
+ # ------------------------------------------------------------------
698
+
699
+ def _map_pickup_groups(self, store: MigrationStore, result: MapperResult) -> None:
700
+ """Map CUCM Pickup Groups to CanonicalPickupGroup objects."""
701
+ for pg_data in store.get_objects("pickup_group"):
702
+ pg_id = pg_data["canonical_id"]
703
+ pg_state = pg_data.get("pre_migration_state") or {}
704
+
705
+ name = (
706
+ pg_state.get("name")
707
+ or pg_state.get("description")
708
+ or pg_id.split(":", 1)[-1]
709
+ )
710
+ members = pg_state.get("members") or []
711
+ # Defensive: extract UUID strings if members are still dicts
712
+ # (normalizer should have cleaned these, but handle pre-existing data)
713
+ raw_member_ids = _extract_agent_ids(members)
714
+
715
+ # Fix 2: Resolve CUCM UUID/DN refs to canonical user IDs.
716
+ # The normalizer stores member UUIDs which may correspond to DNs.
717
+ # Try to find DN objects by matching CUCM UUID in the store, then
718
+ # resolve DN→user via the same device chain as hunt groups.
719
+ agents = _resolve_pickup_members_to_owners(store, raw_member_ids)
720
+
721
+ # Fix 3: Resolve feature location from agents
722
+ location_id = _resolve_feature_location(
723
+ store, agents, pg_data.get("provenance")
724
+ )
725
+
726
+ feature_cid = f"pickup_group:{hash_id(pg_id)}"
727
+ pg = CanonicalPickupGroup(
728
+ canonical_id=feature_cid,
729
+ provenance=extract_provenance(pg_data),
730
+ status=MigrationStatus.ANALYZED,
731
+ name=name,
732
+ agents=agents,
733
+ location_id=location_id,
734
+ )
735
+ store.upsert_object(pg)
736
+ result.objects_created += 1
737
+
738
+ # Fix 8: Write feature_has_agent cross-refs
739
+ for agent_cid in agents:
740
+ store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
741
+
742
+ # ------------------------------------------------------------------
743
+ # Paging Group
744
+ # ------------------------------------------------------------------
745
+
746
+ def _map_paging_groups(self, store: MigrationStore, result: MapperResult) -> None:
747
+ """Map CUCM paging groups to CanonicalPagingGroup objects.
748
+
749
+ Webex paging groups support up to 75 targets. If CUCM exceeds this,
750
+ the mapper splits into multiple groups.
751
+ (from 03b-transform-mappers.md §8: "Paging Group members" edge case)
752
+ """
753
+ for pag_data in store.get_objects("paging_group"):
754
+ pag_id = pag_data["canonical_id"]
755
+ pag_state = pag_data.get("pre_migration_state") or {}
756
+
757
+ name = (
758
+ pag_state.get("name")
759
+ or pag_state.get("description")
760
+ or pag_id.split(":", 1)[-1]
761
+ )
762
+ extension = pag_state.get("extension")
763
+ targets = pag_state.get("targets") or []
764
+ originators = pag_state.get("originators") or []
765
+
766
+ if len(targets) <= _MAX_PAGING_TARGETS:
767
+ pg = CanonicalPagingGroup(
768
+ canonical_id=f"paging_group:{hash_id(pag_id)}",
769
+ provenance=extract_provenance(pag_data),
770
+ status=MigrationStatus.ANALYZED,
771
+ name=name,
772
+ extension=extension,
773
+ targets=targets,
774
+ originators=originators,
775
+ )
776
+ store.upsert_object(pg)
777
+ result.objects_created += 1
778
+ else:
779
+ # Split targets into chunks of 75
780
+ for chunk_idx, start in enumerate(
781
+ range(0, len(targets), _MAX_PAGING_TARGETS)
782
+ ):
783
+ chunk = targets[start:start + _MAX_PAGING_TARGETS]
784
+ split_name = f"{name}-{chunk_idx + 1}"
785
+ pg = CanonicalPagingGroup(
786
+ canonical_id=f"paging_group:{hash_id(f'{pag_id}-{chunk_idx}')}",
787
+ provenance=extract_provenance(pag_data),
788
+ status=MigrationStatus.ANALYZED,
789
+ name=split_name,
790
+ extension=extension if chunk_idx == 0 else None,
791
+ targets=chunk,
792
+ originators=originators,
793
+ )
794
+ store.upsert_object(pg)
795
+ result.objects_created += 1
796
+
797
+ # ------------------------------------------------------------------
798
+ # Schedules: Time Schedule + Time Period -> CanonicalOperatingMode
799
+ # (from 03b-transform-mappers.md §8: schedule -> OperatingMode)
800
+ # ------------------------------------------------------------------
801
+
802
+ def _map_schedules(self, store: MigrationStore, result: MapperResult) -> None:
803
+ """Map CUCM Time Schedules (with Time Periods) to CanonicalOperatingMode.
804
+
805
+ Reads schedule_has_time_period cross-ref (#25) to join schedules with
806
+ their time period entries.
807
+
808
+ (from 03b-transform-mappers.md §8: OperatingMode field mapping)
809
+ """
810
+ for sched_data in store.get_objects("time_schedule"):
811
+ sched_id = sched_data["canonical_id"]
812
+ sched_state = sched_data.get("pre_migration_state") or {}
813
+
814
+ name = (
815
+ sched_state.get("name")
816
+ or sched_state.get("description")
817
+ or sched_id.split(":", 1)[-1]
818
+ )
819
+
820
+ # --- Resolve time periods via cross-ref (#25) ---
821
+ period_refs = store.find_cross_refs(sched_id, "schedule_has_time_period")
822
+ time_periods: list[dict[str, Any]] = []
823
+ for tp_ref in period_refs:
824
+ tp_data = store.get_object(tp_ref)
825
+ if tp_data:
826
+ tp_state = tp_data.get("pre_migration_state") or {}
827
+ time_periods.append(tp_state)
828
+
829
+ # --- Determine schedule type ---
830
+ # Heuristic: if any period has a specific date -> HOLIDAY
831
+ # if all periods have same start/end -> SAME_HOURS_DAILY
832
+ # otherwise DIFFERENT_HOURS_DAILY
833
+ schedule_type = sched_state.get("schedule_type")
834
+ if not schedule_type:
835
+ schedule_type = _infer_schedule_type(time_periods)
836
+
837
+ # --- Build schedule data based on type ---
838
+ same_hours_daily = None
839
+ different_hours_daily = None
840
+ holidays: list[dict[str, Any]] = []
841
+
842
+ if schedule_type == "HOLIDAY":
843
+ holidays = [
844
+ {
845
+ "name": tp.get("name", "Holiday"),
846
+ "date": tp.get("date"),
847
+ "recurrence": tp.get("recurrence"),
848
+ }
849
+ for tp in time_periods
850
+ ]
851
+ elif schedule_type == "SAME_HOURS_DAILY" and time_periods:
852
+ # Use first period's hours for all days
853
+ tp = time_periods[0]
854
+ same_hours_daily = {
855
+ "startTime": tp.get("start_time"),
856
+ "endTime": tp.get("end_time"),
857
+ }
858
+ elif schedule_type == "DIFFERENT_HOURS_DAILY" and time_periods:
859
+ different_hours_daily = {
860
+ (tp.get("day_of_week") or f"day_{i}"): {
861
+ "startTime": tp.get("start_time"),
862
+ "endTime": tp.get("end_time"),
863
+ }
864
+ for i, tp in enumerate(time_periods)
865
+ }
866
+
867
+ om = CanonicalOperatingMode(
868
+ canonical_id=f"operating_mode:{hash_id(sched_id)}",
869
+ provenance=extract_provenance(sched_data),
870
+ status=MigrationStatus.ANALYZED,
871
+ name=name,
872
+ level="ORGANIZATION",
873
+ schedule_type=schedule_type,
874
+ same_hours_daily=same_hours_daily,
875
+ different_hours_daily=different_hours_daily,
876
+ holidays=holidays,
877
+ )
878
+ store.upsert_object(om)
879
+ result.objects_created += 1
880
+
881
+ # ------------------------------------------------------------------
882
+ # Location Schedules: create for schedules referenced by AAs
883
+ # (from fix-schedule-mapping-and-skill-gaps.md Fix 2)
884
+ # ------------------------------------------------------------------
885
+
886
+ def _map_location_schedules(
887
+ self, store: MigrationStore, result: MapperResult
888
+ ) -> None:
889
+ """Create CanonicalLocationSchedule for schedules referenced by AAs.
890
+
891
+ AAs need location schedules (not operating modes) for businessSchedule.
892
+ This second pass finds AA-referenced schedules and creates corresponding
893
+ location schedule objects.
894
+ """
895
+ # Collect schedule names referenced by auto attendants
896
+ # schedule_name -> (location_id, [aa_canonical_ids])
897
+ aa_schedule_refs: dict[str, tuple[str, list[str]]] = {}
898
+ for aa_data in store.get_objects("auto_attendant"):
899
+ aa_state = aa_data.get("pre_migration_state") or {}
900
+ sched = (
901
+ aa_data.get("business_schedule")
902
+ or aa_state.get("business_schedule")
903
+ )
904
+ if not sched:
905
+ continue
906
+ loc_id = aa_data.get("location_id")
907
+ if sched in aa_schedule_refs:
908
+ aa_schedule_refs[sched][1].append(aa_data["canonical_id"])
909
+ elif loc_id:
910
+ aa_schedule_refs[sched] = (loc_id, [aa_data["canonical_id"]])
911
+
912
+ if not aa_schedule_refs:
913
+ return
914
+
915
+ # For each operating mode, check if it's referenced by an AA
916
+ for om_data in store.get_objects("operating_mode"):
917
+ om_name = om_data.get("name")
918
+ if om_name not in aa_schedule_refs:
919
+ continue
920
+
921
+ location_id, aa_cids = aa_schedule_refs[om_name]
922
+ om_cid = om_data["canonical_id"]
923
+ sched_type = _om_type_to_schedule_type(om_data.get("schedule_type"))
924
+ events = _build_schedule_events(om_data)
925
+
926
+ sched = CanonicalLocationSchedule(
927
+ canonical_id=f"schedule:{hash_id(om_cid)}",
928
+ provenance=extract_provenance(om_data),
929
+ status=MigrationStatus.ANALYZED,
930
+ name=om_name,
931
+ schedule_type=sched_type,
932
+ location_id=location_id,
933
+ events=events,
934
+ operating_mode_canonical_id=om_cid,
935
+ )
936
+ store.upsert_object(sched)
937
+ result.objects_created += 1
938
+
939
+ # Write aa_has_schedule cross-ref (Fix 3a)
940
+ for aa_cid in aa_cids:
941
+ store.add_cross_ref(aa_cid, sched.canonical_id, "aa_has_schedule")
942
+
943
+
944
+ # ---------------------------------------------------------------------------
945
+ # Helpers
946
+ # ---------------------------------------------------------------------------
947
+
948
+
949
+ def _om_type_to_schedule_type(om_type: str | None) -> str:
950
+ """Map operating mode schedule_type to location schedule type."""
951
+ if om_type == "HOLIDAY":
952
+ return "holidays"
953
+ return "businessHours"
954
+
955
+
956
+ def _build_schedule_events(om_data: dict[str, Any]) -> list[dict[str, Any]]:
957
+ """Convert operating mode time data to location schedule event format.
958
+
959
+ Location schedule API requires recurWeekly recurrence (NOT recurForEver).
960
+ """
961
+ schedule_type = om_data.get("schedule_type")
962
+ events: list[dict[str, Any]] = []
963
+
964
+ if schedule_type == "HOLIDAY":
965
+ for holiday in om_data.get("holidays") or []:
966
+ date = holiday.get("date", "2026-01-01")
967
+ events.append({
968
+ "name": holiday.get("name", "Holiday"),
969
+ "startDate": date,
970
+ "endDate": date,
971
+ "startTime": "00:00",
972
+ "endTime": "23:59",
973
+ "allDayEnabled": True,
974
+ })
975
+ elif schedule_type == "SAME_HOURS_DAILY":
976
+ hours = om_data.get("same_hours_daily") or {}
977
+ start = hours.get("startTime", "08:00")
978
+ end = hours.get("endTime", "17:00")
979
+ events.append({
980
+ "name": om_data.get("name", "Business Hours"),
981
+ "startDate": "2026-01-01",
982
+ "endDate": "2030-12-31",
983
+ "startTime": start,
984
+ "endTime": end,
985
+ "recurrence": {
986
+ "recurWeekly": {
987
+ "sunday": False,
988
+ "monday": True,
989
+ "tuesday": True,
990
+ "wednesday": True,
991
+ "thursday": True,
992
+ "friday": True,
993
+ "saturday": False,
994
+ }
995
+ },
996
+ })
997
+ elif schedule_type == "DIFFERENT_HOURS_DAILY":
998
+ day_data = om_data.get("different_hours_daily") or {}
999
+ day_map = {
1000
+ "monday": "Monday",
1001
+ "tuesday": "Tuesday",
1002
+ "wednesday": "Wednesday",
1003
+ "thursday": "Thursday",
1004
+ "friday": "Friday",
1005
+ "saturday": "Saturday",
1006
+ "sunday": "Sunday",
1007
+ }
1008
+ for day_key, day_label in day_map.items():
1009
+ day_hours = day_data.get(day_key)
1010
+ if not day_hours:
1011
+ continue
1012
+ recurrence_days = {d: (d == day_key) for d in day_map}
1013
+ events.append({
1014
+ "name": f"{om_data.get('name', 'Hours')} - {day_label}",
1015
+ "startDate": "2026-01-01",
1016
+ "endDate": "2030-12-31",
1017
+ "startTime": day_hours.get("startTime", "08:00"),
1018
+ "endTime": day_hours.get("endTime", "17:00"),
1019
+ "recurrence": {"recurWeekly": recurrence_days},
1020
+ })
1021
+ else:
1022
+ # Fallback: all-the-time schedule
1023
+ events.append({
1024
+ "name": om_data.get("name", "All the time"),
1025
+ "startDate": "2026-01-01",
1026
+ "endDate": "2030-12-31",
1027
+ "startTime": "00:00",
1028
+ "endTime": "23:59",
1029
+ "recurrence": {
1030
+ "recurWeekly": {
1031
+ "sunday": True,
1032
+ "monday": True,
1033
+ "tuesday": True,
1034
+ "wednesday": True,
1035
+ "thursday": True,
1036
+ "friday": True,
1037
+ "saturday": True,
1038
+ }
1039
+ },
1040
+ })
1041
+
1042
+ return events
1043
+
1044
+
1045
+ def _infer_schedule_type(time_periods: list[dict[str, Any]]) -> str:
1046
+ """Infer Webex OperatingMode schedule type from CUCM time periods.
1047
+
1048
+ Returns one of: SAME_HOURS_DAILY, DIFFERENT_HOURS_DAILY, HOLIDAY
1049
+ """
1050
+ if not time_periods:
1051
+ return "SAME_HOURS_DAILY"
1052
+
1053
+ # Check for holiday indicators
1054
+ if any(tp.get("date") or tp.get("is_holiday") for tp in time_periods):
1055
+ return "HOLIDAY"
1056
+
1057
+ # Check if all periods have the same start/end time
1058
+ times = {(tp.get("start_time"), tp.get("end_time")) for tp in time_periods}
1059
+ if len(times) == 1:
1060
+ return "SAME_HOURS_DAILY"
1061
+
1062
+ return "DIFFERENT_HOURS_DAILY"
1063
+
1064
+
1065
+ def _build_line_uuid_to_dn(store: MigrationStore) -> dict[str, str]:
1066
+ """Build a lookup from CUCM line-level UUIDs to DN canonical IDs.
1067
+
1068
+ Raw phone objects (stored as phone:{name}) contain per-line dirn dicts
1069
+ with uuid, pattern, and routePartitionName. Pickup group members reference
1070
+ lines by these UUIDs. This builds the mapping so UUIDs can resolve to
1071
+ dn:{pattern}:{partition} canonical IDs.
1072
+ """
1073
+ uuid_to_dn: dict[str, str] = {}
1074
+ for phone_data in store.get_objects("phone"):
1075
+ state = phone_data.get("pre_migration_state") or {}
1076
+ lines = state.get("lines") or {}
1077
+ line_list = lines if isinstance(lines, list) else lines.get("line", [])
1078
+ if isinstance(line_list, dict):
1079
+ line_list = [line_list]
1080
+ if not isinstance(line_list, list):
1081
+ continue
1082
+ for line_entry in line_list:
1083
+ if not isinstance(line_entry, dict):
1084
+ continue
1085
+ dirn = line_entry.get("dirn")
1086
+ if not isinstance(dirn, dict):
1087
+ continue
1088
+ uuid = dirn.get("uuid")
1089
+ pattern = dirn.get("pattern")
1090
+ if not uuid or not pattern:
1091
+ continue
1092
+ # Extract partition from routePartitionName (zeep ref or string)
1093
+ rp = dirn.get("routePartitionName")
1094
+ if isinstance(rp, dict):
1095
+ partition = rp.get("_value_1") or "<None>"
1096
+ elif isinstance(rp, str):
1097
+ partition = rp or "<None>"
1098
+ else:
1099
+ partition = "<None>"
1100
+ dn_id = f"dn:{pattern}:{partition}"
1101
+ uuid_to_dn[str(uuid)] = dn_id
1102
+ return uuid_to_dn
1103
+
1104
+
1105
+ def _resolve_pickup_members_to_owners(
1106
+ store: MigrationStore, member_ids: list[str]
1107
+ ) -> list[str]:
1108
+ """Resolve pickup group member IDs (CUCM UUIDs) to canonical user/workspace IDs.
1109
+
1110
+ Pickup group members may be:
1111
+ - Already canonical IDs (e.g., user:alice) — pass through directly
1112
+ - DN canonical IDs (e.g., dn:1002:PT-Internal) — resolve via device chain
1113
+ - CUCM UUIDs (e.g., {5782CE0C-...}) — resolve via raw phone line data
1114
+ Falls back to the raw member_id if resolution fails.
1115
+ """
1116
+ # Build one-time lookup: line-level UUID → DN canonical_id from raw phone data
1117
+ uuid_to_dn: dict[str, str] | None = None
1118
+
1119
+ seen: set[str] = set()
1120
+ resolved: list[str] = []
1121
+
1122
+ for member_id in member_ids:
1123
+ owner = None
1124
+
1125
+ if member_id.startswith("user:") or member_id.startswith("workspace:"):
1126
+ # Already a canonical user/workspace ID — use directly
1127
+ owner = member_id
1128
+ elif member_id.startswith("dn:"):
1129
+ # DN canonical_id — resolve via device chain
1130
+ owner = _resolve_dn_to_owner(store, member_id)
1131
+ else:
1132
+ # CUCM UUID — resolve via raw phone line data
1133
+ if uuid_to_dn is None:
1134
+ uuid_to_dn = _build_line_uuid_to_dn(store)
1135
+
1136
+ dn_cid = uuid_to_dn.get(member_id)
1137
+ if dn_cid:
1138
+ owner = _resolve_dn_to_owner(store, dn_cid)
1139
+
1140
+ agent_id = owner if owner else member_id
1141
+ if agent_id not in seen:
1142
+ seen.add(agent_id)
1143
+ resolved.append(agent_id)
1144
+
1145
+ return resolved
1146
+
1147
+
1148
+ def _extract_agent_ids(members: list) -> list[str]:
1149
+ """Extract string IDs from a member list that may contain raw CUCM dicts.
1150
+
1151
+ If members are already strings, they pass through unchanged. If they are
1152
+ dicts (e.g. from pre-existing store data not re-normalized), extract the
1153
+ UUID from nested zeep reference fields.
1154
+ """
1155
+ result: list[str] = []
1156
+ for member in members:
1157
+ if isinstance(member, str):
1158
+ result.append(member)
1159
+ elif isinstance(member, dict):
1160
+ # Look for UUID in nested reference fields
1161
+ uuid_val = None
1162
+ for key, val in member.items():
1163
+ if key == "priority":
1164
+ continue
1165
+ if isinstance(val, dict) and "uuid" in val and val["uuid"] is not None:
1166
+ uuid_val = str(val["uuid"])
1167
+ break
1168
+ if isinstance(val, str) and (val.startswith("{") or len(val) == 36):
1169
+ uuid_val = val
1170
+ break
1171
+ if uuid_val is None and "uuid" in member:
1172
+ uuid_val = str(member["uuid"]) if member["uuid"] is not None else None
1173
+ if uuid_val:
1174
+ result.append(uuid_val)
1175
+ else:
1176
+ logger.warning("Could not extract UUID from pickup group member: %s", member)
1177
+ return result
1178
+
1179
+