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,122 @@
1
+ """Device compatibility analyzer — finds incompatible devices.
2
+
3
+ Mapper-owned decision type: device_mapper may already produce DEVICE_INCOMPATIBLE
4
+ decisions. This analyzer backfills any devices the mapper missed (e.g., devices
5
+ added after initial mapping).
6
+
7
+ Convertible devices are NOT surfaced as decisions. Convertibility is a phone
8
+ model classification (``compatibility_tier == 'convertible'``), not an operator
9
+ choice — the planner unconditionally emits an activation-code op for those.
10
+
11
+ (from 03-conflict-detection-engine.md, device compatibility linter)
12
+ (from phase-06-analyzers.md, Decision Ownership Table — mapper-owned)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import DecisionOption, DecisionType
20
+ from wxcli.migration.store import MigrationStore
21
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
22
+
23
+
24
+ class DeviceCompatibilityAnalyzer(Analyzer):
25
+ """Detects devices that are incompatible with Webex Calling.
26
+
27
+ Sweeps all device objects and checks ``compatibility_tier``:
28
+ - ``incompatible`` -> DEVICE_INCOMPATIBLE decision
29
+ - ``convertible`` -> no decision (planner emits conversion op unconditionally)
30
+ - ``native_mpp`` -> no decision needed
31
+
32
+ Skips devices that already have a mapper-produced decision for the same type.
33
+ """
34
+
35
+ name = "device_compatibility"
36
+ decision_types = [DecisionType.DEVICE_INCOMPATIBLE]
37
+ depends_on: list[str] = []
38
+
39
+ def analyze(self, store: MigrationStore) -> list[Decision]:
40
+ decisions: list[Decision] = []
41
+
42
+ # Get existing mapper-produced decisions indexed by affected object canonical_id
43
+ existing_incompatible = self._get_existing_decisions_for_type(
44
+ store, DecisionType.DEVICE_INCOMPATIBLE
45
+ )
46
+
47
+ devices = store.get_objects("device")
48
+
49
+ for device in devices:
50
+ canonical_id = device.get("canonical_id", "")
51
+ tier = device.get("compatibility_tier")
52
+
53
+ if tier in ("webex_app", "infrastructure", "dect", "convertible"):
54
+ # Software phones transition to Webex App; infrastructure devices
55
+ # (CTI, CER, gateways) are CUCM-only; DECT is handled via DECT network
56
+ # provisioning; convertible devices always convert (no operator choice).
57
+ continue
58
+
59
+ elif tier == "incompatible":
60
+ # Skip if mapper already produced this decision for this device
61
+ if canonical_id in existing_incompatible:
62
+ continue
63
+ decisions.append(self._build_incompatible_decision(store, device))
64
+
65
+ # native_mpp and None — no decision needed
66
+
67
+ return decisions
68
+
69
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
70
+ return self._hash_fingerprint({
71
+ "canonical_id": context.get("canonical_id", ""),
72
+ "model": context.get("model", ""),
73
+ "compatibility_tier": context.get("compatibility_tier", ""),
74
+ })
75
+
76
+ # ------------------------------------------------------------------
77
+ # Decision builders
78
+ # ------------------------------------------------------------------
79
+
80
+ def _build_incompatible_decision(
81
+ self, store: MigrationStore, device: dict[str, Any]
82
+ ) -> Decision:
83
+ canonical_id = device.get("canonical_id", "")
84
+ model = device.get("model", "unknown")
85
+ mac = device.get("mac", "")
86
+ cucm_device_name = device.get("cucm_device_name", "")
87
+ owner_canonical_id = device.get("owner_canonical_id", "")
88
+
89
+ context = {
90
+ "canonical_id": canonical_id,
91
+ "model": model,
92
+ "mac": mac,
93
+ "cucm_device_name": cucm_device_name,
94
+ "owner_canonical_id": owner_canonical_id,
95
+ "compatibility_tier": "incompatible",
96
+ }
97
+ options = [
98
+ DecisionOption(
99
+ id="skip",
100
+ label="Skip device",
101
+ impact=f"Device {model} ({cucm_device_name}) will not be migrated",
102
+ ),
103
+ DecisionOption(
104
+ id="manual",
105
+ label="Handle manually",
106
+ impact=f"Device {model} ({cucm_device_name}) flagged for manual replacement",
107
+ ),
108
+ ]
109
+ affected = [canonical_id]
110
+ if owner_canonical_id:
111
+ affected.append(owner_canonical_id)
112
+
113
+ return self._create_decision(
114
+ store=store,
115
+ decision_type=DecisionType.DEVICE_INCOMPATIBLE,
116
+ severity="MEDIUM",
117
+ summary=f"Device {model} ({cucm_device_name}) is incompatible with Webex Calling",
118
+ context=context,
119
+ options=options,
120
+ affected_objects=affected,
121
+ )
122
+
@@ -0,0 +1,131 @@
1
+ """DN ambiguity analyzer — finds lines with AMBIGUOUS E.164 classification.
2
+
3
+ The line_mapper's E.164 normalization algorithm classifies each DN as
4
+ EXTENSION, NATIONAL, E164, or AMBIGUOUS. This analyzer surfaces the
5
+ AMBIGUOUS cases so a human can make the call.
6
+
7
+ Decision ownership: MAPPER-OWNED (line_mapper produces DN_AMBIGUOUS decisions).
8
+ This analyzer checks for existing mapper decisions before creating new ones.
9
+
10
+ (from 03-conflict-detection-engine.md, DN ambiguity detection)
11
+ (from 03b-transform-mappers.md, line_mapper E.164 algorithm lines 208-211)
12
+ (from 07-idempotency-resumability.md, fingerprint-based identity)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import DecisionOption, DecisionType
20
+ from wxcli.migration.store import MigrationStore
21
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
22
+
23
+
24
+ class DNAmbiguityAnalyzer(Analyzer):
25
+ """Finds DNs where E.164 classification is AMBIGUOUS.
26
+
27
+ The line_mapper may already have produced DN_AMBIGUOUS decisions for some
28
+ lines. This analyzer only creates decisions for ambiguous lines that
29
+ don't already have one, acting as a safety net for anything the mapper
30
+ missed.
31
+ """
32
+
33
+ name = "dn_ambiguity"
34
+ decision_types = [DecisionType.DN_AMBIGUOUS]
35
+
36
+ def analyze(self, store: MigrationStore) -> list[Decision]:
37
+ """Sweep all lines, find AMBIGUOUS classification, skip already-decided."""
38
+ decisions: list[Decision] = []
39
+
40
+ # Step 1: Get existing mapper-produced DN_AMBIGUOUS decisions
41
+ existing = self._get_existing_decisions_for_type(store, DecisionType.DN_AMBIGUOUS)
42
+
43
+ # Step 2: Load all lines and filter for AMBIGUOUS classification
44
+ lines = store.get_objects("line")
45
+ if not lines:
46
+ return decisions
47
+
48
+ for line in lines:
49
+ classification = line.get("classification")
50
+ if classification != "AMBIGUOUS":
51
+ continue
52
+
53
+ canonical_id = line.get("canonical_id", "")
54
+ if not canonical_id:
55
+ continue
56
+
57
+ # Skip if mapper already produced a decision for this line
58
+ if canonical_id in existing:
59
+ continue
60
+
61
+ dn_pattern = line.get("cucm_pattern", "")
62
+ partition = line.get("route_partition_name", "")
63
+ # Count fixed (non-wildcard) digits after stripping + prefix
64
+ # e.g., "+1555XXXX" -> "1555XXXX" -> count chars that aren't X/x/. -> 4
65
+ stripped = dn_pattern.lstrip("+")
66
+ dn_length = sum(1 for c in stripped if c not in "Xx.![]")
67
+
68
+ severity = "LOW" if dn_length <= 4 else "MEDIUM"
69
+
70
+ context = {
71
+ "dn": dn_pattern,
72
+ "partition": partition,
73
+ "dn_length": dn_length,
74
+ "canonical_id": canonical_id,
75
+ }
76
+
77
+ options = [
78
+ DecisionOption(
79
+ id="extension_only",
80
+ label="Treat as Extension",
81
+ impact=f"Map '{dn_pattern}' as internal extension only — no DID",
82
+ ),
83
+ DecisionOption(
84
+ id="national",
85
+ label="Treat as National Number",
86
+ impact=f"Map '{dn_pattern}' as national-format DID",
87
+ ),
88
+ DecisionOption(
89
+ id="e164",
90
+ label="Treat as E.164",
91
+ impact=f"Map '{dn_pattern}' as full E.164 DID with + prefix",
92
+ ),
93
+ DecisionOption(
94
+ id="skip",
95
+ label="Skip",
96
+ impact="Leave DN unmapped — manual intervention required",
97
+ ),
98
+ ]
99
+
100
+ decision = self._create_decision(
101
+ store=store,
102
+ decision_type=DecisionType.DN_AMBIGUOUS,
103
+ severity=severity,
104
+ summary=(
105
+ f"DN '{dn_pattern}' in partition '{partition}' has ambiguous "
106
+ f"E.164 classification ({dn_length} digits)"
107
+ ),
108
+ context=context,
109
+ options=options,
110
+ affected_objects=[canonical_id],
111
+ )
112
+ decisions.append(decision)
113
+
114
+ return decisions
115
+
116
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
117
+ """Deterministic fingerprint from DN pattern, partition, and line ID.
118
+
119
+ Includes:
120
+ - dn (the pattern that's ambiguous)
121
+ - partition (routing scope — different partitions are different decisions)
122
+ - canonical_id (the specific line object)
123
+
124
+ (from 07-idempotency-resumability.md lines 30-43)
125
+ """
126
+ return self._hash_fingerprint({
127
+ "type": decision_type.value,
128
+ "dn": context.get("dn"),
129
+ "partition": context.get("partition"),
130
+ "canonical_id": context.get("canonical_id"),
131
+ })
@@ -0,0 +1,221 @@
1
+ """Duplicate user analyzer — finds users with matching email or name across CUCM inventory.
2
+
3
+ Detects users that would collide in Webex because they share the same email
4
+ address, or users without emails who share the same (first_name, last_name)
5
+ pair. Duplicate users block migration because Webex requires unique email
6
+ identities.
7
+
8
+ Decision ownership: ANALYZER-OWNED (no mapper produces DUPLICATE_USER).
9
+
10
+ (from 03-conflict-detection-engine.md, duplicate user detection)
11
+ (from 07-idempotency-resumability.md, fingerprint-based identity)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import DecisionOption, DecisionType
20
+ from wxcli.migration.store import MigrationStore
21
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
22
+
23
+
24
+ class DuplicateUserAnalyzer(Analyzer):
25
+ """Finds users with matching email or name across CUCM inventory.
26
+
27
+ Webex requires each person to have a unique email address. This analyzer
28
+ detects two collision patterns:
29
+
30
+ 1. **Email collisions**: 2+ users share the same email (case-insensitive).
31
+ 2. **Name collisions (no email)**: 2+ users with empty emails share the
32
+ same (first_name, last_name) pair — likely the same person entered
33
+ multiple times in CUCM.
34
+
35
+ Both patterns produce DUPLICATE_USER decisions with HIGH severity since
36
+ they block migration.
37
+ """
38
+
39
+ name = "duplicate_user"
40
+ decision_types = [DecisionType.DUPLICATE_USER]
41
+
42
+ def analyze(self, store: MigrationStore) -> list[Decision]:
43
+ """Sweep all users, group by email and name, flag duplicates."""
44
+ decisions: list[Decision] = []
45
+
46
+ users = store.get_objects("user")
47
+ if not users:
48
+ return decisions
49
+
50
+ # Step 1: Group users by lowercase email
51
+ email_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
52
+ # Track users with no email for name-based duplicate check
53
+ no_email_users: list[dict[str, Any]] = []
54
+
55
+ for user in users:
56
+ emails = user.get("emails", [])
57
+ if emails:
58
+ for email in emails:
59
+ email_lower = email.strip().lower()
60
+ if email_lower:
61
+ email_groups[email_lower].append(user)
62
+ else:
63
+ no_email_users.append(user)
64
+
65
+ # Step 2: Produce decisions for email-based duplicates (2+ users per email)
66
+ seen_ids: set[str] = set() # track which users already have a decision
67
+
68
+ for email, group_users in sorted(email_groups.items()):
69
+ if len(group_users) < 2:
70
+ continue
71
+
72
+ # Deduplicate by canonical_id within the group (a user with multiple
73
+ # emails could appear twice in the same group)
74
+ unique_users: dict[str, dict[str, Any]] = {}
75
+ for u in group_users:
76
+ cid = u.get("canonical_id", "")
77
+ if cid:
78
+ unique_users[cid] = u
79
+
80
+ if len(unique_users) < 2:
81
+ continue
82
+
83
+ user_ids = sorted(unique_users.keys())
84
+ user_names = [
85
+ unique_users[uid].get("display_name")
86
+ or f"{unique_users[uid].get('first_name', '')} {unique_users[uid].get('last_name', '')}".strip()
87
+ or uid
88
+ for uid in user_ids
89
+ ]
90
+
91
+ context = {
92
+ "email": email,
93
+ "user_ids": user_ids,
94
+ "user_names": user_names,
95
+ }
96
+
97
+ options = [
98
+ DecisionOption(
99
+ id="merge",
100
+ label="Merge",
101
+ impact=f"Merge {len(user_ids)} users into single Webex person",
102
+ ),
103
+ DecisionOption(
104
+ id="keep_primary",
105
+ label="Keep Primary",
106
+ impact=f"Keep first user, skip {len(user_ids) - 1} others",
107
+ ),
108
+ DecisionOption(
109
+ id="skip_all",
110
+ label="Skip All",
111
+ impact=f"Skip all {len(user_ids)} duplicate users",
112
+ ),
113
+ DecisionOption(
114
+ id="manual",
115
+ label="Manual Resolution",
116
+ impact="Flag for manual review and resolution",
117
+ ),
118
+ ]
119
+
120
+ decision = self._create_decision(
121
+ store=store,
122
+ decision_type=DecisionType.DUPLICATE_USER,
123
+ severity="HIGH",
124
+ summary=f"Email {email} shared by {len(user_ids)} users",
125
+ context=context,
126
+ options=options,
127
+ affected_objects=user_ids,
128
+ )
129
+ decisions.append(decision)
130
+ seen_ids.update(user_ids)
131
+
132
+ # Step 3: Check no-email users for (first_name, last_name) collisions
133
+ name_groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
134
+
135
+ for user in no_email_users:
136
+ cid = user.get("canonical_id", "")
137
+ if cid in seen_ids:
138
+ continue # already covered by an email-based decision
139
+
140
+ first = (user.get("first_name") or "").strip().lower()
141
+ last = (user.get("last_name") or "").strip().lower()
142
+ if first or last:
143
+ name_groups[(first, last)].append(user)
144
+
145
+ for (first, last), group_users in sorted(name_groups.items()):
146
+ if len(group_users) < 2:
147
+ continue
148
+
149
+ user_ids = sorted(u.get("canonical_id", "") for u in group_users if u.get("canonical_id"))
150
+ if len(user_ids) < 2:
151
+ continue
152
+
153
+ display_name = f"{first} {last}".strip()
154
+ user_names = [
155
+ u.get("display_name") or display_name or u.get("canonical_id", "")
156
+ for u in group_users
157
+ if u.get("canonical_id")
158
+ ]
159
+
160
+ # Use a synthetic email key for context — no real email exists
161
+ name_key = f"<no-email>:{first}:{last}"
162
+
163
+ context = {
164
+ "email": name_key,
165
+ "user_ids": user_ids,
166
+ "user_names": sorted(user_names),
167
+ }
168
+
169
+ options = [
170
+ DecisionOption(
171
+ id="merge",
172
+ label="Merge",
173
+ impact=f"Merge {len(user_ids)} users into single Webex person",
174
+ ),
175
+ DecisionOption(
176
+ id="keep_primary",
177
+ label="Keep Primary",
178
+ impact=f"Keep first user, skip {len(user_ids) - 1} others",
179
+ ),
180
+ DecisionOption(
181
+ id="skip_all",
182
+ label="Skip All",
183
+ impact=f"Skip all {len(user_ids)} duplicate users",
184
+ ),
185
+ DecisionOption(
186
+ id="manual",
187
+ label="Manual Resolution",
188
+ impact="Flag for manual review and resolution",
189
+ ),
190
+ ]
191
+
192
+ decision = self._create_decision(
193
+ store=store,
194
+ decision_type=DecisionType.DUPLICATE_USER,
195
+ severity="HIGH",
196
+ summary=(
197
+ f"Name '{display_name}' shared by {len(user_ids)} users "
198
+ f"(no email set)"
199
+ ),
200
+ context=context,
201
+ options=options,
202
+ affected_objects=user_ids,
203
+ )
204
+ decisions.append(decision)
205
+
206
+ return decisions
207
+
208
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
209
+ """Deterministic fingerprint from shared email and affected user IDs.
210
+
211
+ Includes:
212
+ - email (the shared email, or synthetic name key for no-email dupes)
213
+ - sorted user canonical_ids (all users in the duplicate group)
214
+
215
+ (from 07-idempotency-resumability.md lines 30-43)
216
+ """
217
+ return self._hash_fingerprint({
218
+ "type": decision_type.value,
219
+ "email": context.get("email"),
220
+ "user_ids": sorted(context.get("user_ids", [])),
221
+ })
@@ -0,0 +1,173 @@
1
+ """Extension conflict analyzer — finds DNs sharing the same extension within a location.
2
+
3
+ Detects lines that would collide in Webex Calling because they share the same
4
+ extension number within the same location. CUCM allows this via partitions,
5
+ but Webex Calling enforces unique extensions per location.
6
+
7
+ Decision ownership: ANALYZER-OWNED (no mapper produces EXTENSION_CONFLICT).
8
+
9
+ (from 03-conflict-detection-engine.md, extension conflict detection)
10
+ (from 07-idempotency-resumability.md, fingerprint-based identity)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections import defaultdict
17
+ from typing import Any
18
+
19
+ from wxcli.migration.models import DecisionOption, DecisionType
20
+ from wxcli.migration.store import MigrationStore
21
+ from wxcli.migration.transform.analyzers import Analyzer, Decision
22
+
23
+
24
+ class ExtensionConflictAnalyzer(Analyzer):
25
+ """Finds extension conflicts within the same Webex Calling location.
26
+
27
+ CUCM allows multiple DNs with the same pattern in different partitions.
28
+ Webex Calling requires unique extensions per location. This analyzer
29
+ groups lines by (extension, location) and flags collisions.
30
+
31
+ Location resolution: line → device (via device_has_dn cross-ref) →
32
+ device.location_canonical_id.
33
+ """
34
+
35
+ name = "extension_conflict"
36
+ decision_types = [DecisionType.EXTENSION_CONFLICT]
37
+
38
+ def analyze(self, store: MigrationStore) -> list[Decision]:
39
+ """Sweep all lines, group by (extension, location), flag conflicts."""
40
+ decisions: list[Decision] = []
41
+
42
+ # Step 1: Load all lines and build a map of canonical_id → line data
43
+ lines = store.get_objects("line")
44
+ if not lines:
45
+ return decisions
46
+
47
+ line_by_id: dict[str, dict[str, Any]] = {}
48
+ for line in lines:
49
+ cid = line.get("canonical_id", "")
50
+ if cid:
51
+ line_by_id[cid] = line
52
+
53
+ # Step 2: For each line, resolve its location via:
54
+ # line ←(device_has_dn)← device → device.location_canonical_id
55
+ # Query cross_refs where relationship='device_has_dn' and to_id is a line
56
+ line_location: dict[str, str] = {} # line canonical_id → location canonical_id
57
+
58
+ device_rows = store.conn.execute(
59
+ "SELECT from_id, to_id FROM cross_refs WHERE relationship = 'device_has_dn'"
60
+ ).fetchall()
61
+
62
+ # Build line → device(s) mapping
63
+ line_to_devices: dict[str, list[str]] = defaultdict(list)
64
+ for row in device_rows:
65
+ line_to_devices[row["to_id"]].append(row["from_id"])
66
+
67
+ # Load all devices once for location lookups
68
+ devices = store.get_objects("device")
69
+ device_location: dict[str, str] = {}
70
+ for dev in devices:
71
+ dev_cid = dev.get("canonical_id", "")
72
+ loc_cid = dev.get("location_canonical_id", "")
73
+ if dev_cid and loc_cid:
74
+ device_location[dev_cid] = loc_cid
75
+
76
+ # Resolve each line's location via its owning device
77
+ for line_cid in line_by_id:
78
+ owning_devices = line_to_devices.get(line_cid, [])
79
+ for dev_cid in owning_devices:
80
+ loc = device_location.get(dev_cid)
81
+ if loc:
82
+ line_location[line_cid] = loc
83
+ break # use the first device that has a location
84
+
85
+ # Step 3: Group lines by (extension, location)
86
+ groups: dict[tuple[str, str], list[str]] = defaultdict(list)
87
+ for line_cid, line_data in line_by_id.items():
88
+ ext = line_data.get("extension")
89
+ loc = line_location.get(line_cid)
90
+ if ext and loc:
91
+ groups[(ext, loc)].append(line_cid)
92
+
93
+ # Step 4: Produce decisions for groups with 2+ lines
94
+ for (ext, loc_cid), conflicting_ids in sorted(groups.items()):
95
+ if len(conflicting_ids) < 2:
96
+ continue
97
+
98
+ severity = "HIGH" if len(conflicting_ids) >= 3 else "MEDIUM"
99
+
100
+ # Build context with details about each conflicting line
101
+ line_details = []
102
+ for cid in sorted(conflicting_ids):
103
+ ld = line_by_id.get(cid, {})
104
+ line_details.append({
105
+ "canonical_id": cid,
106
+ "cucm_pattern": ld.get("cucm_pattern"),
107
+ "route_partition_name": ld.get("route_partition_name"),
108
+ })
109
+
110
+ context = {
111
+ "extension": ext,
112
+ "location_canonical_id": loc_cid,
113
+ "conflict_count": len(conflicting_ids),
114
+ "conflicting_lines": line_details,
115
+ }
116
+
117
+ options = [
118
+ DecisionOption(
119
+ id="renumber",
120
+ label="Renumber Extension",
121
+ impact=f"Change extension for {len(conflicting_ids) - 1} of "
122
+ f"{len(conflicting_ids)} conflicting lines",
123
+ ),
124
+ DecisionOption(
125
+ id="virtual_line",
126
+ label="Use Virtual Line",
127
+ impact=f"Create virtual line(s) for {len(conflicting_ids) - 1} "
128
+ f"conflicting appearances",
129
+ ),
130
+ DecisionOption(
131
+ id="skip",
132
+ label="Skip",
133
+ impact=f"Leave {len(conflicting_ids)} lines unresolved — "
134
+ f"manual intervention required",
135
+ ),
136
+ ]
137
+
138
+ decision = self._create_decision(
139
+ store=store,
140
+ decision_type=DecisionType.EXTENSION_CONFLICT,
141
+ severity=severity,
142
+ summary=(
143
+ f"Extension {ext} used by {len(conflicting_ids)} lines "
144
+ f"in location {loc_cid}"
145
+ ),
146
+ context=context,
147
+ options=options,
148
+ affected_objects=sorted(conflicting_ids),
149
+ )
150
+ decisions.append(decision)
151
+
152
+ return decisions
153
+
154
+ def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
155
+ """Deterministic fingerprint from extension, location, and affected line IDs.
156
+
157
+ Includes:
158
+ - extension (the triggering condition)
159
+ - location_canonical_id (scope of the conflict)
160
+ - sorted canonical_ids of all conflicting lines (affected objects)
161
+
162
+ (from 07-idempotency-resumability.md lines 30-43)
163
+ """
164
+ conflicting_ids = sorted(
165
+ line.get("canonical_id", "")
166
+ for line in context.get("conflicting_lines", [])
167
+ )
168
+ return self._hash_fingerprint({
169
+ "type": decision_type.value,
170
+ "extension": context.get("extension"),
171
+ "location_canonical_id": context.get("location_canonical_id"),
172
+ "conflicting_line_ids": conflicting_ids,
173
+ })