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.
- wxcli/__init__.py +4 -0
- wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
- wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
- wxcli/_playbook/.claude/rules/cleanup.md +29 -0
- wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
- wxcli/_playbook/.claude/rules/org-health.md +14 -0
- wxcli/_playbook/.claude/settings.json +8 -0
- wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
- wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
- wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
- wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
- wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
- wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
- wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
- wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
- wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
- wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
- wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
- wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
- wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
- wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
- wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
- wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
- wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
- wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
- wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
- wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
- wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
- wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
- wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
- wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
- wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
- wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
- wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
- wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
- wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
- wxcli/_playbook/CLAUDE.md +373 -0
- wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
- wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
- wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
- wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
- wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
- wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
- wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
- wxcli/_playbook/docs/reference/admin-partner.md +357 -0
- wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
- wxcli/_playbook/docs/reference/authentication.md +965 -0
- wxcli/_playbook/docs/reference/call-control.md +1205 -0
- wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
- wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
- wxcli/_playbook/docs/reference/call-routing.md +2304 -0
- wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
- wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
- wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
- wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
- wxcli/_playbook/docs/reference/devices-core.md +1869 -0
- wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
- wxcli/_playbook/docs/reference/devices-platform.md +467 -0
- wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
- wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
- wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
- wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
- wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
- wxcli/_playbook/docs/reference/meetings-content.md +473 -0
- wxcli/_playbook/docs/reference/meetings-core.md +903 -0
- wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
- wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
- wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
- wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
- wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
- wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
- wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
- wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
- wxcli/_playbook/docs/reference/provisioning.md +1366 -0
- wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
- wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
- wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
- wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
- wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
- wxcli/_version.py +24 -0
- wxcli/auth.py +137 -0
- wxcli/commands/__init__.py +0 -0
- wxcli/commands/_registry.py +181 -0
- wxcli/commands/activation_email.py +103 -0
- wxcli/commands/admin_recordings.py +585 -0
- wxcli/commands/analytics.py +105 -0
- wxcli/commands/announcement_playlists.py +219 -0
- wxcli/commands/announcements.py +519 -0
- wxcli/commands/archive_users.py +36 -0
- wxcli/commands/attachment_actions.py +60 -0
- wxcli/commands/audit_events.py +83 -0
- wxcli/commands/authorizations.py +113 -0
- wxcli/commands/auto_attendant.py +642 -0
- wxcli/commands/broadworks_billing_reports.py +126 -0
- wxcli/commands/broadworks_enterprises.py +160 -0
- wxcli/commands/broadworks_subscribers.py +319 -0
- wxcli/commands/broadworks_workspaces.py +108 -0
- wxcli/commands/call_controls.py +1304 -0
- wxcli/commands/call_park.py +494 -0
- wxcli/commands/call_pickup.py +229 -0
- wxcli/commands/call_queue.py +1878 -0
- wxcli/commands/call_recording.py +707 -0
- wxcli/commands/call_routing.py +1610 -0
- wxcli/commands/call_settings_for_me_phase_5.py +212 -0
- wxcli/commands/caller_reputation.py +169 -0
- wxcli/commands/calling_service.py +305 -0
- wxcli/commands/cc_address_book.py +770 -0
- wxcli/commands/cc_agent_greetings.py +242 -0
- wxcli/commands/cc_agent_summaries.py +39 -0
- wxcli/commands/cc_agent_wellbeing.py +216 -0
- wxcli/commands/cc_agents.py +425 -0
- wxcli/commands/cc_ai_assistant.py +55 -0
- wxcli/commands/cc_ai_feature.py +294 -0
- wxcli/commands/cc_audio_files.py +149 -0
- wxcli/commands/cc_auto_csat.py +311 -0
- wxcli/commands/cc_aux_code.py +458 -0
- wxcli/commands/cc_business_hour.py +353 -0
- wxcli/commands/cc_call_monitoring.py +228 -0
- wxcli/commands/cc_callbacks.py +194 -0
- wxcli/commands/cc_campaign.py +212 -0
- wxcli/commands/cc_captures.py +39 -0
- wxcli/commands/cc_contact_list.py +169 -0
- wxcli/commands/cc_contact_number.py +295 -0
- wxcli/commands/cc_data_sources.py +235 -0
- wxcli/commands/cc_desktop_layout.py +380 -0
- wxcli/commands/cc_desktop_profile.py +322 -0
- wxcli/commands/cc_dial_number.py +511 -0
- wxcli/commands/cc_dial_plan.py +347 -0
- wxcli/commands/cc_dnc.py +101 -0
- wxcli/commands/cc_entry_point.py +545 -0
- wxcli/commands/cc_ewt.py +51 -0
- wxcli/commands/cc_flow.py +477 -0
- wxcli/commands/cc_global_vars.py +430 -0
- wxcli/commands/cc_holiday_list.py +335 -0
- wxcli/commands/cc_journey.py +1261 -0
- wxcli/commands/cc_legacy_flows.py +49 -0
- wxcli/commands/cc_multimedia_profile.py +436 -0
- wxcli/commands/cc_notification.py +51 -0
- wxcli/commands/cc_outdial_ani.py +604 -0
- wxcli/commands/cc_overrides.py +341 -0
- wxcli/commands/cc_queue.py +1083 -0
- wxcli/commands/cc_queue_stats.py +55 -0
- wxcli/commands/cc_realtime.py +51 -0
- wxcli/commands/cc_resource_collection.py +312 -0
- wxcli/commands/cc_search.py +46 -0
- wxcli/commands/cc_site.py +400 -0
- wxcli/commands/cc_skill.py +350 -0
- wxcli/commands/cc_skill_profile.py +289 -0
- wxcli/commands/cc_subscriptions.py +426 -0
- wxcli/commands/cc_summaries.py +130 -0
- wxcli/commands/cc_tasks.py +844 -0
- wxcli/commands/cc_team.py +325 -0
- wxcli/commands/cc_user_profiles.py +349 -0
- wxcli/commands/cc_users.py +701 -0
- wxcli/commands/cc_work_types.py +363 -0
- wxcli/commands/cdr.py +89 -0
- wxcli/commands/classifications.py +38 -0
- wxcli/commands/cleanup.py +1427 -0
- wxcli/commands/client_settings.py +72 -0
- wxcli/commands/conference.py +328 -0
- wxcli/commands/configure.py +72 -0
- wxcli/commands/converged_recordings.py +347 -0
- wxcli/commands/converged_recordings_export.py +310 -0
- wxcli/commands/cq_playlists.py +42 -0
- wxcli/commands/cucm.py +2794 -0
- wxcli/commands/cucm_config.py +129 -0
- wxcli/commands/customer_assist.py +373 -0
- wxcli/commands/data_sources.py +227 -0
- wxcli/commands/dect_devices.py +787 -0
- wxcli/commands/device_configurations.py +77 -0
- wxcli/commands/device_dynamic_settings.py +320 -0
- wxcli/commands/device_settings.py +1489 -0
- wxcli/commands/devices.py +280 -0
- wxcli/commands/domains.py +142 -0
- wxcli/commands/ecm.py +178 -0
- wxcli/commands/emergency_services.py +827 -0
- wxcli/commands/events.py +83 -0
- wxcli/commands/external_voicemail.py +52 -0
- wxcli/commands/groups.py +206 -0
- wxcli/commands/guest_management.py +76 -0
- wxcli/commands/hds.py +340 -0
- wxcli/commands/hot_desk.py +67 -0
- wxcli/commands/hot_desking_members.py +118 -0
- wxcli/commands/hot_desking_portal.py +124 -0
- wxcli/commands/hunt_group.py +577 -0
- wxcli/commands/hybrid_clusters.py +71 -0
- wxcli/commands/hybrid_connectors.py +67 -0
- wxcli/commands/identity_org.py +159 -0
- wxcli/commands/init_playbook.py +155 -0
- wxcli/commands/licenses.py +106 -0
- wxcli/commands/live_monitoring.py +40 -0
- wxcli/commands/location_call_handling.py +577 -0
- wxcli/commands/location_schedules.py +341 -0
- wxcli/commands/location_settings.py +1297 -0
- wxcli/commands/location_voicemail.py +494 -0
- wxcli/commands/locations.py +336 -0
- wxcli/commands/meeting_captions.py +110 -0
- wxcli/commands/meeting_chats.py +63 -0
- wxcli/commands/meeting_invitees.py +234 -0
- wxcli/commands/meeting_messages.py +28 -0
- wxcli/commands/meeting_participants.py +278 -0
- wxcli/commands/meeting_polls.py +111 -0
- wxcli/commands/meeting_preferences.py +515 -0
- wxcli/commands/meeting_qa.py +78 -0
- wxcli/commands/meeting_qualities.py +43 -0
- wxcli/commands/meeting_reports.py +101 -0
- wxcli/commands/meeting_session_types.py +105 -0
- wxcli/commands/meeting_site.py +57 -0
- wxcli/commands/meeting_slido.py +44 -0
- wxcli/commands/meeting_summaries.py +92 -0
- wxcli/commands/meeting_tracking_codes.py +233 -0
- wxcli/commands/meeting_transcripts.py +230 -0
- wxcli/commands/meetings.py +1997 -0
- wxcli/commands/memberships.py +164 -0
- wxcli/commands/messages.py +213 -0
- wxcli/commands/mode_management.py +244 -0
- wxcli/commands/my_call_settings.py +3577 -0
- wxcli/commands/numbers.py +405 -0
- wxcli/commands/operating_modes.py +410 -0
- wxcli/commands/org_contacts.py +284 -0
- wxcli/commands/org_health_cli.py +55 -0
- wxcli/commands/org_settings.py +71 -0
- wxcli/commands/organizations.py +83 -0
- wxcli/commands/paging_group.py +257 -0
- wxcli/commands/partner_admins.py +141 -0
- wxcli/commands/partner_reports.py +188 -0
- wxcli/commands/partner_tags.py +216 -0
- wxcli/commands/people.py +279 -0
- wxcli/commands/person_call_settings.py +71 -0
- wxcli/commands/pstn.py +280 -0
- wxcli/commands/recording_report.py +154 -0
- wxcli/commands/report_templates.py +38 -0
- wxcli/commands/reports.py +144 -0
- wxcli/commands/resource_group_memberships.py +160 -0
- wxcli/commands/resource_groups.py +67 -0
- wxcli/commands/roles.py +63 -0
- wxcli/commands/room_tabs.py +160 -0
- wxcli/commands/rooms.py +225 -0
- wxcli/commands/scim_bulk.py +46 -0
- wxcli/commands/scim_groups.py +246 -0
- wxcli/commands/scim_schemas.py +82 -0
- wxcli/commands/scim_users.py +283 -0
- wxcli/commands/security_audit.py +54 -0
- wxcli/commands/service_apps.py +51 -0
- wxcli/commands/single_number_reach.py +209 -0
- wxcli/commands/team_memberships.py +155 -0
- wxcli/commands/teams.py +153 -0
- wxcli/commands/ucm_profile.py +42 -0
- wxcli/commands/update.py +219 -0
- wxcli/commands/user_settings.py +5116 -0
- wxcli/commands/video_mesh.py +1090 -0
- wxcli/commands/virtual_extensions.py +502 -0
- wxcli/commands/virtual_line_settings.py +2072 -0
- wxcli/commands/webhooks.py +176 -0
- wxcli/commands/wholesale_billing_reports.py +147 -0
- wxcli/commands/wholesale_provisioning.py +523 -0
- wxcli/commands/workspace_locations.py +339 -0
- wxcli/commands/workspace_metrics.py +103 -0
- wxcli/commands/workspace_personalization.py +70 -0
- wxcli/commands/workspace_settings.py +3347 -0
- wxcli/commands/workspaces.py +279 -0
- wxcli/commands/xapi.py +114 -0
- wxcli/config.py +139 -0
- wxcli/errors.py +77 -0
- wxcli/main.py +210 -0
- wxcli/migration/CLAUDE.md +106 -0
- wxcli/migration/__init__.py +1 -0
- wxcli/migration/advisory/CLAUDE.md +196 -0
- wxcli/migration/advisory/__init__.py +46 -0
- wxcli/migration/advisory/advisor.py +107 -0
- wxcli/migration/advisory/advisory_patterns.py +2552 -0
- wxcli/migration/advisory/recommendation_rules.py +835 -0
- wxcli/migration/cucm/CLAUDE.md +74 -0
- wxcli/migration/cucm/__init__.py +5 -0
- wxcli/migration/cucm/connection.py +265 -0
- wxcli/migration/cucm/discovery.py +225 -0
- wxcli/migration/cucm/extractors/__init__.py +1 -0
- wxcli/migration/cucm/extractors/announcements.py +92 -0
- wxcli/migration/cucm/extractors/base.py +90 -0
- wxcli/migration/cucm/extractors/device_profiles.py +98 -0
- wxcli/migration/cucm/extractors/devices.py +193 -0
- wxcli/migration/cucm/extractors/e911.py +103 -0
- wxcli/migration/cucm/extractors/features.py +309 -0
- wxcli/migration/cucm/extractors/helpers.py +73 -0
- wxcli/migration/cucm/extractors/informational.py +295 -0
- wxcli/migration/cucm/extractors/locations.py +221 -0
- wxcli/migration/cucm/extractors/moh.py +100 -0
- wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
- wxcli/migration/cucm/extractors/routing.py +493 -0
- wxcli/migration/cucm/extractors/shared_lines.py +146 -0
- wxcli/migration/cucm/extractors/templates.py +229 -0
- wxcli/migration/cucm/extractors/tier4.py +173 -0
- wxcli/migration/cucm/extractors/users.py +240 -0
- wxcli/migration/cucm/extractors/voicemail.py +243 -0
- wxcli/migration/cucm/extractors/workspaces.py +88 -0
- wxcli/migration/cucm/unity_connection.py +304 -0
- wxcli/migration/execute/CLAUDE.md +372 -0
- wxcli/migration/execute/__init__.py +303 -0
- wxcli/migration/execute/batch.py +277 -0
- wxcli/migration/execute/dependency.py +483 -0
- wxcli/migration/execute/engine.py +964 -0
- wxcli/migration/execute/handlers.py +2232 -0
- wxcli/migration/execute/planner.py +1939 -0
- wxcli/migration/execute/runtime.py +571 -0
- wxcli/migration/export/CLAUDE.md +49 -0
- wxcli/migration/export/__init__.py +8 -0
- wxcli/migration/export/csv_export.py +126 -0
- wxcli/migration/export/deployment_plan.py +479 -0
- wxcli/migration/export/json_export.py +60 -0
- wxcli/migration/models.py +941 -0
- wxcli/migration/phone_models.py +50 -0
- wxcli/migration/preflight/CLAUDE.md +52 -0
- wxcli/migration/preflight/__init__.py +89 -0
- wxcli/migration/preflight/checks.py +911 -0
- wxcli/migration/preflight/runner.py +293 -0
- wxcli/migration/rate_limiter.py +144 -0
- wxcli/migration/report/CLAUDE.md +245 -0
- wxcli/migration/report/__init__.py +7 -0
- wxcli/migration/report/appendix.py +2437 -0
- wxcli/migration/report/assembler.py +183 -0
- wxcli/migration/report/charts.py +263 -0
- wxcli/migration/report/executive.py +776 -0
- wxcli/migration/report/explainer.py +704 -0
- wxcli/migration/report/helpers.py +74 -0
- wxcli/migration/report/ingest.py +152 -0
- wxcli/migration/report/notice_templates.py +192 -0
- wxcli/migration/report/score.py +375 -0
- wxcli/migration/report/styles.py +1052 -0
- wxcli/migration/report/user_diff.py +1052 -0
- wxcli/migration/report/user_notice.py +425 -0
- wxcli/migration/state.py +139 -0
- wxcli/migration/store.py +860 -0
- wxcli/migration/transform/CLAUDE.md +107 -0
- wxcli/migration/transform/__init__.py +12 -0
- wxcli/migration/transform/analysis_pipeline.py +453 -0
- wxcli/migration/transform/analyzers/__init__.py +142 -0
- wxcli/migration/transform/analyzers/css_permission.py +189 -0
- wxcli/migration/transform/analyzers/css_routing.py +335 -0
- wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
- wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
- wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
- wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
- wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
- wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
- wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
- wxcli/migration/transform/analyzers/missing_data.py +300 -0
- wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
- wxcli/migration/transform/analyzers/shared_line.py +170 -0
- wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
- wxcli/migration/transform/analyzers/workspace_license.py +212 -0
- wxcli/migration/transform/cross_reference.py +1136 -0
- wxcli/migration/transform/cucm_pattern.py +246 -0
- wxcli/migration/transform/decisions.py +301 -0
- wxcli/migration/transform/e164.py +145 -0
- wxcli/migration/transform/engine.py +256 -0
- wxcli/migration/transform/mappers/CLAUDE.md +123 -0
- wxcli/migration/transform/mappers/__init__.py +76 -0
- wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
- wxcli/migration/transform/mappers/base.py +190 -0
- wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
- wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
- wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
- wxcli/migration/transform/mappers/css_mapper.py +868 -0
- wxcli/migration/transform/mappers/dect_mapper.py +367 -0
- wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
- wxcli/migration/transform/mappers/device_mapper.py +227 -0
- wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
- wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
- wxcli/migration/transform/mappers/e911_mapper.py +129 -0
- wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
- wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
- wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
- wxcli/migration/transform/mappers/line_mapper.py +310 -0
- wxcli/migration/transform/mappers/location_mapper.py +235 -0
- wxcli/migration/transform/mappers/moh_mapper.py +114 -0
- wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
- wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
- wxcli/migration/transform/mappers/routing_mapper.py +821 -0
- wxcli/migration/transform/mappers/snr_mapper.py +201 -0
- wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
- wxcli/migration/transform/mappers/user_mapper.py +264 -0
- wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
- wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
- wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
- wxcli/migration/transform/normalizers.py +2008 -0
- wxcli/migration/transform/pattern_converter.py +106 -0
- wxcli/migration/transform/pipeline.py +271 -0
- wxcli/migration/transform/rules.py +255 -0
- wxcli/org_health/CLAUDE.md +63 -0
- wxcli/org_health/__init__.py +7 -0
- wxcli/org_health/analyze.py +93 -0
- wxcli/org_health/checks.py +458 -0
- wxcli/org_health/collector.py +62 -0
- wxcli/org_health/models.py +78 -0
- wxcli/org_health/report.py +300 -0
- wxcli/output.py +101 -0
- wxcli-1.2.0.dist-info/METADATA +505 -0
- wxcli-1.2.0.dist-info/RECORD +413 -0
- wxcli-1.2.0.dist-info/WHEEL +5 -0
- wxcli-1.2.0.dist-info/entry_points.txt +2 -0
- wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
- wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
- wxcli-1.2.0.dist-info/scm_version.json +8 -0
- wxcli-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pattern converter: CUCM route pattern -> Webex dial plan pattern.
|
|
2
|
+
|
|
3
|
+
Converts CUCM dial pattern syntax to Webex-compatible E.164 format patterns.
|
|
4
|
+
Used by routing_mapper and css_mapper for pattern syntax conversion.
|
|
5
|
+
|
|
6
|
+
(from 03b-transform-mappers.md §6, Pattern Syntax Conversion table)
|
|
7
|
+
(from 03b-transform-mappers.md §13, Pattern Converter shared utility)
|
|
8
|
+
|
|
9
|
+
Conversion rules:
|
|
10
|
+
- Strip access code prefix (everything before '.')
|
|
11
|
+
- Prepend country code prefix (e.g., "+1") to the remaining pattern
|
|
12
|
+
- Preserve wildcards: X, !, [ranges]
|
|
13
|
+
- Strip '.' separator entirely
|
|
14
|
+
- International access code (e.g., "011") is stripped when followed by wildcard
|
|
15
|
+
- '@' macro requires expansion (not handled here — see migration config)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cucm_to_webex_pattern(
|
|
22
|
+
cucm_pattern: str,
|
|
23
|
+
country_code: str = "+1",
|
|
24
|
+
outside_dial_digit: str = "9",
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Convert a CUCM route pattern to a Webex dial plan pattern.
|
|
27
|
+
|
|
28
|
+
(from 03b-transform-mappers.md §13: Pattern Converter)
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cucm_pattern: CUCM route pattern (e.g., "9.1[2-9]XXXXXXXXX", "9.011!")
|
|
32
|
+
country_code: E.164 country prefix including '+' (e.g., "+1", "+44")
|
|
33
|
+
outside_dial_digit: The CUCM outside access digit (e.g., "9")
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Webex-format dial plan pattern (e.g., "+1[2-9]XXXXXXXXX", "+!")
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
>>> cucm_to_webex_pattern("9.1[2-9]XXXXXXXXX", "+1", "9")
|
|
40
|
+
'+1[2-9]XXXXXXXXX'
|
|
41
|
+
>>> cucm_to_webex_pattern("9.011!", "+1", "9")
|
|
42
|
+
'+!'
|
|
43
|
+
>>> cucm_to_webex_pattern("1XXX", "+1", "9")
|
|
44
|
+
'+11XXX'
|
|
45
|
+
>>> cucm_to_webex_pattern("\\\\+1XXXXXXXXXX", "+1", "9")
|
|
46
|
+
'+1XXXXXXXXXX'
|
|
47
|
+
"""
|
|
48
|
+
if not cucm_pattern:
|
|
49
|
+
return cucm_pattern
|
|
50
|
+
|
|
51
|
+
# Coerce outside_dial_digit to str (config may supply it as int, e.g. 9)
|
|
52
|
+
outside_dial_digit = str(outside_dial_digit)
|
|
53
|
+
|
|
54
|
+
# Strip leading backslash-escaped '+' (CUCM uses \+ for literal plus)
|
|
55
|
+
# (CUCM route patterns sometimes escape + as \\+)
|
|
56
|
+
working = cucm_pattern.replace("\\+", "+")
|
|
57
|
+
|
|
58
|
+
# If pattern already starts with '+', it's already E.164 format
|
|
59
|
+
if working.startswith("+"):
|
|
60
|
+
return working
|
|
61
|
+
|
|
62
|
+
# Track whether we stripped an access code. When an access code is present,
|
|
63
|
+
# the remainder typically starts with the national prefix (e.g., "1" for US).
|
|
64
|
+
# Without an access code, the pattern is a direct route pattern where digits
|
|
65
|
+
# should NOT be interpreted as embedded country codes.
|
|
66
|
+
access_code_stripped = False
|
|
67
|
+
|
|
68
|
+
# Strip access code prefix: everything before '.' (the dot separator)
|
|
69
|
+
# (from 03b-transform-mappers.md §6: "Strips access code prefix (before '.')")
|
|
70
|
+
if "." in working:
|
|
71
|
+
_prefix, remainder = working.split(".", 1)
|
|
72
|
+
working = remainder
|
|
73
|
+
access_code_stripped = True
|
|
74
|
+
# If no dot but starts with outside_dial_digit followed by known patterns,
|
|
75
|
+
# strip the leading access code
|
|
76
|
+
elif working.startswith(outside_dial_digit) and len(working) > 1:
|
|
77
|
+
next_char = working[len(outside_dial_digit):]
|
|
78
|
+
if next_char and (next_char[0].isdigit() or next_char[0] in "X[!"):
|
|
79
|
+
working = working[len(outside_dial_digit):]
|
|
80
|
+
access_code_stripped = True
|
|
81
|
+
|
|
82
|
+
# Handle international access code patterns (e.g., "011!")
|
|
83
|
+
# (from 03b-transform-mappers.md §6: "9.011!" -> "+!")
|
|
84
|
+
# Common international access codes: 011 (US/CA), 00 (many countries)
|
|
85
|
+
intl_prefixes = ["011", "00"]
|
|
86
|
+
for intl in intl_prefixes:
|
|
87
|
+
if working.startswith(intl):
|
|
88
|
+
remainder_after_intl = working[len(intl):]
|
|
89
|
+
if remainder_after_intl:
|
|
90
|
+
return f"+{remainder_after_intl}"
|
|
91
|
+
else:
|
|
92
|
+
return f"+{working}"
|
|
93
|
+
|
|
94
|
+
# If we stripped an access code, the remainder starts with the national
|
|
95
|
+
# prefix digits (e.g., "1" for "+1"). Just prepend "+".
|
|
96
|
+
# (from 03b-transform-mappers.md §6: "Strip access code prefix, prepend + and country code")
|
|
97
|
+
# Example: "9.1[2-9]XXXXXXXXX" -> strip "9." -> "1[2-9]XXXXXXXXX" -> "+1[2-9]XXXXXXXXX"
|
|
98
|
+
if access_code_stripped:
|
|
99
|
+
bare_cc = country_code.lstrip("+")
|
|
100
|
+
if bare_cc and working.startswith(bare_cc):
|
|
101
|
+
return f"+{working}"
|
|
102
|
+
|
|
103
|
+
# Prepend full country code prefix for patterns without embedded national prefix
|
|
104
|
+
# (from 03b-transform-mappers.md §6: "prepend + and country code")
|
|
105
|
+
# country_code already includes '+' (e.g., "+1")
|
|
106
|
+
return f"{country_code}{working}"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Normalization pipeline: DiscoveryResult → normalized store → cross-references.
|
|
2
|
+
|
|
3
|
+
This is the Phase 04 entry point. It connects Phase 03 extraction output
|
|
4
|
+
(DiscoveryResult.raw_data) to the normalizers and CrossReferenceBuilder.
|
|
5
|
+
|
|
6
|
+
Pipeline steps:
|
|
7
|
+
1. Iterate raw_data using RAW_DATA_MAPPING to call the right normalizer per item
|
|
8
|
+
2. Classify common-area phones as workspaces (post-normalization)
|
|
9
|
+
3. Normalize Unity Connection per-user VM settings (if present)
|
|
10
|
+
4. Upsert all normalized objects to the MigrationStore
|
|
11
|
+
5. Run CrossReferenceBuilder (pass 2)
|
|
12
|
+
|
|
13
|
+
(from 02-normalization-architecture.md, two-pass ELT design)
|
|
14
|
+
(from Phase 03 DiscoveryResult.raw_data contract)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
|
|
24
|
+
from wxcli.migration.models import MigrationObject, MigrationStatus, Provenance
|
|
25
|
+
from wxcli.migration.store import MigrationStore
|
|
26
|
+
from wxcli.migration.transform.cross_reference import CrossReferenceBuilder
|
|
27
|
+
from wxcli.migration.transform.normalizers import (
|
|
28
|
+
NORMALIZER_REGISTRY,
|
|
29
|
+
RAW_DATA_MAPPING,
|
|
30
|
+
is_common_area_device,
|
|
31
|
+
normalize_dect_group,
|
|
32
|
+
normalize_unity_vm_settings,
|
|
33
|
+
normalize_workspace,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_discovery(
|
|
40
|
+
raw_data: dict[str, dict[str, Any]],
|
|
41
|
+
store: MigrationStore,
|
|
42
|
+
cluster: str = "default",
|
|
43
|
+
default_country_code: str = "US",
|
|
44
|
+
site_prefix_rules: list[dict] | None = None,
|
|
45
|
+
dect_inventory: list[dict[str, str]] | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Run the full normalization pipeline on discovery raw_data.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
raw_data: DiscoveryResult.raw_data from Phase 03 extraction.
|
|
51
|
+
store: MigrationStore to write normalized objects to.
|
|
52
|
+
cluster: CUCM cluster name for provenance.
|
|
53
|
+
default_country_code: ISO country code for E.164 normalization.
|
|
54
|
+
site_prefix_rules: Prefix stripping rules for E.164 normalization.
|
|
55
|
+
dect_inventory: Optional list of base station dicts from --dect-inventory
|
|
56
|
+
CSV. Each dict has keys: coverage_zone, base_station_mac,
|
|
57
|
+
base_station_model. Passed through to normalize_dect_group() so
|
|
58
|
+
that inventory data is merged into CanonicalDECTNetwork objects.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Summary dict with counts per step.
|
|
62
|
+
"""
|
|
63
|
+
summary: dict[str, Any] = {"pass1": {}, "pass2": {}}
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Pass 1: Normalize all objects from raw_data
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
total_normalized = 0
|
|
70
|
+
|
|
71
|
+
for extractor_key, sub_key, normalizer_key in RAW_DATA_MAPPING:
|
|
72
|
+
normalizer_fn = NORMALIZER_REGISTRY.get(normalizer_key)
|
|
73
|
+
if normalizer_fn is None:
|
|
74
|
+
logger.warning("No normalizer for %s/%s (key: %s)", extractor_key, sub_key, normalizer_key)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
extractor_data = raw_data.get(extractor_key, {})
|
|
78
|
+
items = extractor_data.get(sub_key, [])
|
|
79
|
+
if not isinstance(items, list):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
count = 0
|
|
83
|
+
for item in items:
|
|
84
|
+
try:
|
|
85
|
+
obj = normalizer_fn(item, cluster=cluster)
|
|
86
|
+
if obj is None:
|
|
87
|
+
continue # Normalizer rejected this item (e.g., empty name)
|
|
88
|
+
store.upsert_object(obj)
|
|
89
|
+
count += 1
|
|
90
|
+
except Exception:
|
|
91
|
+
logger.exception(
|
|
92
|
+
"Failed to normalize %s/%s item: %s",
|
|
93
|
+
extractor_key, sub_key,
|
|
94
|
+
item.get("name", item.get("pattern", item.get("pkid", "?"))),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
summary["pass1"][f"{extractor_key}/{sub_key}"] = count
|
|
98
|
+
total_normalized += count
|
|
99
|
+
if count > 0:
|
|
100
|
+
logger.info("Normalized %d %s/%s objects", count, extractor_key, sub_key)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Preserve raw phone objects for mappers (§8 fix)
|
|
104
|
+
# normalize_phone() creates CanonicalDevice (object_type="device") but
|
|
105
|
+
# MonitoringMapper, CallForwardingMapper, DeviceLayoutMapper, DeviceMapper,
|
|
106
|
+
# and WorkspaceMapper all call store.get_objects("phone") expecting raw
|
|
107
|
+
# phone dicts with speeddials, busyLampFields, and per-line call forwarding.
|
|
108
|
+
# Store raw phone data as MigrationObject with canonical_id="phone:{name}".
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
phones = raw_data.get("devices", {}).get("phones", [])
|
|
112
|
+
raw_phone_count = 0
|
|
113
|
+
for phone in phones:
|
|
114
|
+
phone_name = phone.get("name", "")
|
|
115
|
+
if not phone_name:
|
|
116
|
+
continue
|
|
117
|
+
raw_obj = MigrationObject(
|
|
118
|
+
canonical_id=f"phone:{phone_name}",
|
|
119
|
+
provenance=Provenance(
|
|
120
|
+
source_system="cucm",
|
|
121
|
+
source_id=phone.get("pkid", ""),
|
|
122
|
+
source_name=phone_name,
|
|
123
|
+
cluster=cluster,
|
|
124
|
+
extracted_at=datetime.now(timezone.utc),
|
|
125
|
+
),
|
|
126
|
+
status=MigrationStatus.NORMALIZED,
|
|
127
|
+
pre_migration_state=phone,
|
|
128
|
+
)
|
|
129
|
+
store.upsert_object(raw_obj)
|
|
130
|
+
raw_phone_count += 1
|
|
131
|
+
|
|
132
|
+
summary["pass1"]["raw_phones_preserved"] = raw_phone_count
|
|
133
|
+
if raw_phone_count > 0:
|
|
134
|
+
logger.info("Preserved %d raw phone objects for mappers", raw_phone_count)
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Post-normalization: classify common-area phones as workspaces
|
|
138
|
+
# (from 02b §2.8: common-area phones have ownerUserName=None, class=Phone)
|
|
139
|
+
# Skip phones whose description matches a known user — those are
|
|
140
|
+
# user-owned phones that just lack an explicit ownerUserName in CUCM.
|
|
141
|
+
# A user can own multiple phones; the cross-ref builder handles this.
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
# Build a simple user lookup for description matching
|
|
145
|
+
_user_names: dict[str, str] = {} # lowercase key → user canonical_id
|
|
146
|
+
for u in store.get_objects("user"):
|
|
147
|
+
uid = (u.get("cucm_userid") or "").lower()
|
|
148
|
+
fn = (u.get("first_name") or "").lower().strip()
|
|
149
|
+
ln = (u.get("last_name") or "").lower().strip()
|
|
150
|
+
cid = u["canonical_id"]
|
|
151
|
+
if uid:
|
|
152
|
+
_user_names[uid] = cid
|
|
153
|
+
if fn and ln:
|
|
154
|
+
_user_names[f"{fn} {ln}"] = cid
|
|
155
|
+
_user_names[f"{ln}, {fn}"] = cid
|
|
156
|
+
_user_names[f"{ln} {fn}"] = cid
|
|
157
|
+
|
|
158
|
+
def _description_matches_user(desc: str) -> bool:
|
|
159
|
+
if not desc:
|
|
160
|
+
return False
|
|
161
|
+
dl = desc.lower().strip()
|
|
162
|
+
if dl in _user_names:
|
|
163
|
+
return True
|
|
164
|
+
# Substring: check if a full name (2+ words) appears in description
|
|
165
|
+
for key in _user_names:
|
|
166
|
+
if " " in key and len(key) >= 4 and key in dl:
|
|
167
|
+
return True
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
workspace_count = 0
|
|
171
|
+
inferred_owner_count = 0
|
|
172
|
+
for phone in phones:
|
|
173
|
+
if is_common_area_device(phone):
|
|
174
|
+
desc = phone.get("description", "")
|
|
175
|
+
if _description_matches_user(desc):
|
|
176
|
+
inferred_owner_count += 1
|
|
177
|
+
logger.info(
|
|
178
|
+
"Skipping workspace for %s — description '%s' matches a user",
|
|
179
|
+
phone.get("name", "?"), desc,
|
|
180
|
+
)
|
|
181
|
+
continue
|
|
182
|
+
ws_obj = normalize_workspace(phone, cluster=cluster)
|
|
183
|
+
store.upsert_object(ws_obj)
|
|
184
|
+
workspace_count += 1
|
|
185
|
+
|
|
186
|
+
# Inject is_common_area flag into the stored phone object
|
|
187
|
+
# so WorkspaceMapper and DeviceMapper can detect common-area phones
|
|
188
|
+
phone_name = phone.get("name", "")
|
|
189
|
+
if phone_name:
|
|
190
|
+
phone_obj = store.get_object(f"phone:{phone_name}")
|
|
191
|
+
if phone_obj:
|
|
192
|
+
pms = dict(phone_obj.get("pre_migration_state") or {})
|
|
193
|
+
pms["is_common_area"] = True
|
|
194
|
+
store.upsert_object(MigrationObject(
|
|
195
|
+
canonical_id=f"phone:{phone_name}",
|
|
196
|
+
provenance=Provenance(
|
|
197
|
+
source_system="cucm",
|
|
198
|
+
source_id=phone.get("pkid", ""),
|
|
199
|
+
source_name=phone_name,
|
|
200
|
+
cluster=cluster,
|
|
201
|
+
extracted_at=datetime.now(timezone.utc),
|
|
202
|
+
),
|
|
203
|
+
status=MigrationStatus.NORMALIZED,
|
|
204
|
+
pre_migration_state=pms,
|
|
205
|
+
))
|
|
206
|
+
|
|
207
|
+
summary["pass1"]["workspaces_classified"] = workspace_count
|
|
208
|
+
summary["pass1"]["device_owners_inferred"] = inferred_owner_count
|
|
209
|
+
if workspace_count > 0:
|
|
210
|
+
logger.info("Classified %d common-area phones as workspaces", workspace_count)
|
|
211
|
+
if inferred_owner_count > 0:
|
|
212
|
+
logger.info(
|
|
213
|
+
"Inferred user ownership for %d phones from description field",
|
|
214
|
+
inferred_owner_count,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Unity Connection per-user VM settings (if present)
|
|
219
|
+
# (from Phase 03 unity_connection.py extract_user_vm_settings())
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
unity_settings = raw_data.get("voicemail", {}).get("unity_user_settings", {})
|
|
223
|
+
unity_count = 0
|
|
224
|
+
if isinstance(unity_settings, dict):
|
|
225
|
+
for userid, settings in unity_settings.items():
|
|
226
|
+
if isinstance(settings, dict):
|
|
227
|
+
try:
|
|
228
|
+
obj = normalize_unity_vm_settings(userid, settings, cluster=cluster)
|
|
229
|
+
store.upsert_object(obj)
|
|
230
|
+
unity_count += 1
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.exception("Failed to normalize Unity VM settings for %s", userid)
|
|
233
|
+
|
|
234
|
+
summary["pass1"]["unity_vm_settings"] = unity_count
|
|
235
|
+
if unity_count > 0:
|
|
236
|
+
logger.info("Normalized %d Unity Connection per-user VM settings", unity_count)
|
|
237
|
+
|
|
238
|
+
summary["pass1"]["total"] = total_normalized + workspace_count + unity_count
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Pass 2: CrossReferenceBuilder
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
builder = CrossReferenceBuilder(
|
|
245
|
+
store,
|
|
246
|
+
default_country_code=default_country_code,
|
|
247
|
+
site_prefix_rules=site_prefix_rules or [],
|
|
248
|
+
)
|
|
249
|
+
pass2_counts = builder.build()
|
|
250
|
+
summary["pass2"] = pass2_counts
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Post-normalization: DECT network grouping
|
|
254
|
+
# Runs after Pass 2 so compatibility_tier has been classified on all devices.
|
|
255
|
+
# Groups DECT handsets by device pool into CanonicalDECTNetwork objects.
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
dect_networks = normalize_dect_group(store, dect_inventory=dect_inventory)
|
|
259
|
+
summary["pass1"]["dect_networks_grouped"] = len(dect_networks)
|
|
260
|
+
if dect_networks:
|
|
261
|
+
logger.info("Grouped %d DECT handsets into %d DECT network(s)", sum(
|
|
262
|
+
len(n.handset_assignments) for n in dect_networks
|
|
263
|
+
), len(dect_networks))
|
|
264
|
+
|
|
265
|
+
logger.info(
|
|
266
|
+
"Normalization complete: %d objects normalized, %d cross-refs built",
|
|
267
|
+
summary["pass1"]["total"],
|
|
268
|
+
sum(v for v in pass2_counts.values() if isinstance(v, int)),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return summary
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Auto-resolution rules engine for migration decisions.
|
|
2
|
+
|
|
3
|
+
Applies configurable rules to automatically resolve pending decisions
|
|
4
|
+
without requiring manual user input. Rules are driven by the config
|
|
5
|
+
dict passed to apply_auto_rules().
|
|
6
|
+
|
|
7
|
+
(from 03-conflict-detection-engine.md, Auto-Resolution Rules)
|
|
8
|
+
(from 03b-transform-mappers.md section 13, TransformEngine integration)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from wxcli.migration.store import MigrationStore
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _match_rule(rule: dict[str, Any], decision: dict[str, Any]) -> bool:
|
|
22
|
+
"""Check if a rule's ``match`` field matches a decision's context.
|
|
23
|
+
|
|
24
|
+
Supports declarative matching only — no eval/exec.
|
|
25
|
+
|
|
26
|
+
Match field format (from 03-conflict-detection-engine.md, Auto-Resolution Rules):
|
|
27
|
+
- Plain key: exact match, or membership if value is a list
|
|
28
|
+
``{"cucm_model": ["7841", "7861"]}`` → context["cucm_model"] in list
|
|
29
|
+
- ``_lte`` suffix: less-than-or-equal comparison
|
|
30
|
+
``{"dn_length_lte": 4}`` → context["dn_length"] <= 4
|
|
31
|
+
- ``_gte`` suffix: greater-than-or-equal comparison
|
|
32
|
+
- ``_contains`` suffix: substring match
|
|
33
|
+
``{"name_contains": "lobby"}`` → "lobby" in context["name"]
|
|
34
|
+
|
|
35
|
+
All match fields use AND logic — every field must match.
|
|
36
|
+
If a match key is not present in the decision's context, the rule is skipped.
|
|
37
|
+
Non-numeric context values with _lte/_gte operators are skipped (no crash).
|
|
38
|
+
"""
|
|
39
|
+
match = rule.get("match")
|
|
40
|
+
if not match or not isinstance(match, dict):
|
|
41
|
+
return True # No match field → type-only rule, always matches
|
|
42
|
+
|
|
43
|
+
context = decision.get("context", {})
|
|
44
|
+
|
|
45
|
+
for key, expected in match.items():
|
|
46
|
+
# Determine operator suffix
|
|
47
|
+
if key.endswith("_lte"):
|
|
48
|
+
base_key = key[:-4]
|
|
49
|
+
actual = context.get(base_key)
|
|
50
|
+
if actual is None:
|
|
51
|
+
return False
|
|
52
|
+
try:
|
|
53
|
+
if float(actual) > float(expected):
|
|
54
|
+
return False
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return False # Non-numeric → skip gracefully
|
|
57
|
+
|
|
58
|
+
elif key.endswith("_gte"):
|
|
59
|
+
base_key = key[:-4]
|
|
60
|
+
actual = context.get(base_key)
|
|
61
|
+
if actual is None:
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
if float(actual) < float(expected):
|
|
65
|
+
return False
|
|
66
|
+
except (ValueError, TypeError):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
elif key.endswith("_contains"):
|
|
70
|
+
base_key = key[:-9]
|
|
71
|
+
actual = context.get(base_key)
|
|
72
|
+
if actual is None:
|
|
73
|
+
return False
|
|
74
|
+
if not isinstance(actual, str) or not isinstance(expected, str):
|
|
75
|
+
return False
|
|
76
|
+
if expected not in actual:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
else:
|
|
80
|
+
# Plain key: exact match or list membership
|
|
81
|
+
actual = context.get(key)
|
|
82
|
+
if actual is None:
|
|
83
|
+
return False
|
|
84
|
+
if isinstance(expected, list):
|
|
85
|
+
if actual not in expected:
|
|
86
|
+
return False
|
|
87
|
+
else:
|
|
88
|
+
if actual != expected:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _synthesized_reason(dec_type: str, choice: str) -> str:
|
|
95
|
+
"""Default reason when a rule has no explicit `reason` field."""
|
|
96
|
+
return f"Auto-rule: {dec_type} → {choice}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_reason(rule: dict[str, Any], dec_type: str, choice: str) -> str:
|
|
100
|
+
"""Pick the reason string for a matched rule.
|
|
101
|
+
|
|
102
|
+
Prefer rule['reason'] if it is a non-empty string. Otherwise (missing,
|
|
103
|
+
None, empty, or non-string), fall back to the synthesized form and
|
|
104
|
+
log a warning for the non-string case.
|
|
105
|
+
"""
|
|
106
|
+
raw = rule.get("reason")
|
|
107
|
+
if isinstance(raw, str) and raw:
|
|
108
|
+
return raw
|
|
109
|
+
if raw is not None and not isinstance(raw, str):
|
|
110
|
+
logger.warning(
|
|
111
|
+
"auto-rule reason field is not a string (got %r), using synthesized reason",
|
|
112
|
+
type(raw).__name__,
|
|
113
|
+
)
|
|
114
|
+
return _synthesized_reason(dec_type, choice)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _iter_matching_resolutions(
|
|
118
|
+
store: MigrationStore,
|
|
119
|
+
config: dict[str, Any],
|
|
120
|
+
):
|
|
121
|
+
"""Yield ``(decision, choice, reason)`` for every pending decision that
|
|
122
|
+
a config rule would resolve. Pure read — does NOT mutate the store.
|
|
123
|
+
|
|
124
|
+
Both ``apply_auto_rules`` and ``preview_auto_rules`` consume this
|
|
125
|
+
generator so they cannot drift on matcher semantics.
|
|
126
|
+
|
|
127
|
+
Walks the same rules in the same order as the pre-refactor
|
|
128
|
+
``apply_auto_rules`` (first-match-wins within a decision type). Skips:
|
|
129
|
+
- already-resolved decisions (``chosen_option is not None``)
|
|
130
|
+
- malformed rules (missing type or choice)
|
|
131
|
+
- rules whose match clause fails
|
|
132
|
+
- rules whose ``choice`` is not in the decision's ``options`` list
|
|
133
|
+
(option-validation parity with ``apply_auto_rules``)
|
|
134
|
+
"""
|
|
135
|
+
rules = config.get("auto_rules", [])
|
|
136
|
+
if not rules:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
valid_rules: list[dict[str, Any]] = []
|
|
140
|
+
for rule in rules:
|
|
141
|
+
if rule.get("type") and rule.get("choice"):
|
|
142
|
+
valid_rules.append(rule)
|
|
143
|
+
else:
|
|
144
|
+
# Surface config typos — a rule missing `type` or `choice` is
|
|
145
|
+
# almost always a typo (e.g., "tpe" or missing quotes). Without
|
|
146
|
+
# this warning, operators see zero auto-applies with no signal
|
|
147
|
+
# about why. The warning includes the rule dict so they can
|
|
148
|
+
# grep config.json for the bad entry.
|
|
149
|
+
logger.warning(
|
|
150
|
+
"Auto-rule missing 'type' or 'choice' — skipping: %r",
|
|
151
|
+
rule,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not valid_rules:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
all_decisions = store.get_all_decisions()
|
|
158
|
+
|
|
159
|
+
for dec in all_decisions:
|
|
160
|
+
if dec.get("chosen_option") is not None:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
dec_type = dec.get("type", "")
|
|
164
|
+
|
|
165
|
+
for rule in valid_rules:
|
|
166
|
+
if rule["type"] != dec_type:
|
|
167
|
+
continue
|
|
168
|
+
if not _match_rule(rule, dec):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
choice = rule["choice"]
|
|
172
|
+
|
|
173
|
+
# Option-validation parity with apply_auto_rules.
|
|
174
|
+
options = dec.get("options", [])
|
|
175
|
+
valid_ids = {opt["id"] for opt in options if isinstance(opt, dict)}
|
|
176
|
+
if valid_ids and choice not in valid_ids:
|
|
177
|
+
logger.warning(
|
|
178
|
+
"Auto-rule choice '%s' for decision %s (type=%s) "
|
|
179
|
+
"is not a valid option. Valid: %s. Skipping.",
|
|
180
|
+
choice,
|
|
181
|
+
dec.get("decision_id", ""),
|
|
182
|
+
dec_type,
|
|
183
|
+
valid_ids,
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
reason = _resolve_reason(rule, dec_type, choice)
|
|
188
|
+
yield dec, choice, reason
|
|
189
|
+
break # First matching rule wins for this decision
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def preview_auto_rules(
|
|
193
|
+
store: MigrationStore,
|
|
194
|
+
config: dict[str, Any],
|
|
195
|
+
) -> list[dict[str, Any]]:
|
|
196
|
+
"""Return the list of pending decisions that current config rules would
|
|
197
|
+
resolve, each augmented with ``auto_choice`` and ``auto_reason`` keys.
|
|
198
|
+
|
|
199
|
+
Pure read: does NOT mutate the store. Used by ``classify_decisions``
|
|
200
|
+
and the CLI preview path in ``wxcli cucm decide --apply-auto``.
|
|
201
|
+
"""
|
|
202
|
+
result: list[dict[str, Any]] = []
|
|
203
|
+
for dec, choice, reason in _iter_matching_resolutions(store, config):
|
|
204
|
+
d = dict(dec)
|
|
205
|
+
d["auto_choice"] = choice
|
|
206
|
+
d["auto_reason"] = reason
|
|
207
|
+
result.append(d)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def apply_auto_rules(store: MigrationStore, config: dict[str, Any]) -> int:
|
|
212
|
+
"""Apply auto-resolution rules from config to pending decisions.
|
|
213
|
+
|
|
214
|
+
Consumes ``_iter_matching_resolutions`` so apply and preview cannot
|
|
215
|
+
drift on matcher semantics.
|
|
216
|
+
|
|
217
|
+
Config format (from 03-conflict-detection-engine.md, Auto-Resolution Rules)::
|
|
218
|
+
|
|
219
|
+
{"auto_rules": [
|
|
220
|
+
{"type": "DEVICE_INCOMPATIBLE", "choice": "skip"},
|
|
221
|
+
{"type": "DN_AMBIGUOUS",
|
|
222
|
+
"match": {"dn_length_lte": 4},
|
|
223
|
+
"choice": "extension_only"},
|
|
224
|
+
]}
|
|
225
|
+
|
|
226
|
+
Match field supports plain values (exact/list membership), ``_lte``/
|
|
227
|
+
``_gte``/``_contains`` suffixes, and AND logic across multiple fields.
|
|
228
|
+
|
|
229
|
+
Each resolved decision gets::
|
|
230
|
+
|
|
231
|
+
chosen_option = <choice from rule>
|
|
232
|
+
resolved_by = "auto_rule"
|
|
233
|
+
resolved_at = current UTC timestamp
|
|
234
|
+
|
|
235
|
+
Returns count of decisions auto-resolved.
|
|
236
|
+
|
|
237
|
+
(from 03-conflict-detection-engine.md, Auto-Resolution Rules)
|
|
238
|
+
"""
|
|
239
|
+
resolved_count = 0
|
|
240
|
+
for dec, choice, _reason in _iter_matching_resolutions(store, config):
|
|
241
|
+
store.resolve_decision(
|
|
242
|
+
decision_id=dec["decision_id"],
|
|
243
|
+
chosen_option=choice,
|
|
244
|
+
resolved_by="auto_rule",
|
|
245
|
+
)
|
|
246
|
+
resolved_count += 1
|
|
247
|
+
logger.debug(
|
|
248
|
+
"Auto-resolved decision %s (type=%s) with choice='%s'",
|
|
249
|
+
dec.get("decision_id", ""),
|
|
250
|
+
dec.get("type", ""),
|
|
251
|
+
choice,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
logger.info("Auto-rules resolved %d decision(s)", resolved_count)
|
|
255
|
+
return resolved_count
|