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,1427 @@
|
|
|
1
|
+
"""Batch-delete Webex Calling resources in dependency-safe order."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from wxcli.errors import WebexError
|
|
13
|
+
|
|
14
|
+
# Location-delete retry tuning: after the initial 90s calling-detach
|
|
15
|
+
# propagation wait, 17-location orgs have been observed to still 409 on
|
|
16
|
+
# first attempt. 5 attempts × 60s = 5 min worst case per laggard location,
|
|
17
|
+
# which fits under typical agent/bash tool timeouts and covers the trailing
|
|
18
|
+
# edge of async calling-config propagation without masking real failures.
|
|
19
|
+
LOCATION_DELETE_MAX_ATTEMPTS = 5
|
|
20
|
+
LOCATION_DELETE_RETRY_SLEEP = 60
|
|
21
|
+
|
|
22
|
+
# Signature of the CCP-integrated PSTN backend gate. When a delete fails with
|
|
23
|
+
# this error code, the org uses Cisco Calling Plan and the resource is managed
|
|
24
|
+
# by the Webex backend — no API action on our side can unblock it.
|
|
25
|
+
CCP_INTEGRATED_ERROR_CODE = "ERR.V.TRM.TMN60004"
|
|
26
|
+
|
|
27
|
+
from wxcli.auth import get_api
|
|
28
|
+
from wxcli.config import get_org_id
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
BASE = "https://webexapis.com/v1"
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
name="cleanup",
|
|
37
|
+
help="Batch-delete Webex Calling resources in dependency-safe order.",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ResourceType:
|
|
44
|
+
"""Definition of a Webex resource type for cleanup."""
|
|
45
|
+
name: str
|
|
46
|
+
list_url: str
|
|
47
|
+
item_key: str
|
|
48
|
+
delete_url: str
|
|
49
|
+
location_scoped_list: bool = False
|
|
50
|
+
supports_location_filter: bool = False
|
|
51
|
+
server_side_location_filter: bool = False
|
|
52
|
+
id_field: str = "id"
|
|
53
|
+
name_field: str = "name"
|
|
54
|
+
extra_delete_fields: list[str] = field(default_factory=list)
|
|
55
|
+
# Extra query params always included when listing this resource type.
|
|
56
|
+
# Used for /people which requires callingData=true for locationId filtering.
|
|
57
|
+
extra_list_params: dict = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
RESOURCE_TYPES: dict[str, ResourceType] = {
|
|
61
|
+
"dial_plans": ResourceType(
|
|
62
|
+
name="Dial Plans",
|
|
63
|
+
list_url="/telephony/config/premisePstn/dialPlans",
|
|
64
|
+
item_key="dialPlans",
|
|
65
|
+
delete_url="/telephony/config/premisePstn/dialPlans/{id}",
|
|
66
|
+
),
|
|
67
|
+
"route_lists": ResourceType(
|
|
68
|
+
name="Route Lists",
|
|
69
|
+
list_url="/telephony/config/premisePstn/routeLists",
|
|
70
|
+
item_key="routeLists",
|
|
71
|
+
delete_url="/telephony/config/premisePstn/routeLists/{id}",
|
|
72
|
+
),
|
|
73
|
+
"route_groups": ResourceType(
|
|
74
|
+
name="Route Groups",
|
|
75
|
+
list_url="/telephony/config/premisePstn/routeGroups",
|
|
76
|
+
item_key="routeGroups",
|
|
77
|
+
delete_url="/telephony/config/premisePstn/routeGroups/{id}",
|
|
78
|
+
),
|
|
79
|
+
"translation_patterns": ResourceType(
|
|
80
|
+
name="Translation Patterns",
|
|
81
|
+
list_url="/telephony/config/callRouting/translationPatterns",
|
|
82
|
+
item_key="translationPatterns",
|
|
83
|
+
delete_url="/telephony/config/callRouting/translationPatterns/{id}",
|
|
84
|
+
),
|
|
85
|
+
"trunks": ResourceType(
|
|
86
|
+
name="Trunks",
|
|
87
|
+
list_url="/telephony/config/premisePstn/trunks",
|
|
88
|
+
item_key="trunks",
|
|
89
|
+
delete_url="/telephony/config/premisePstn/trunks/{id}",
|
|
90
|
+
),
|
|
91
|
+
"hunt_groups": ResourceType(
|
|
92
|
+
name="Hunt Groups",
|
|
93
|
+
list_url="/telephony/config/huntGroups",
|
|
94
|
+
item_key="huntGroups",
|
|
95
|
+
delete_url="/telephony/config/locations/{location_id}/huntGroups/{id}",
|
|
96
|
+
supports_location_filter=True,
|
|
97
|
+
),
|
|
98
|
+
"auto_attendants": ResourceType(
|
|
99
|
+
name="Auto Attendants",
|
|
100
|
+
list_url="/telephony/config/autoAttendants",
|
|
101
|
+
item_key="autoAttendants",
|
|
102
|
+
delete_url="/telephony/config/locations/{location_id}/autoAttendants/{id}",
|
|
103
|
+
supports_location_filter=True,
|
|
104
|
+
),
|
|
105
|
+
"call_queues": ResourceType(
|
|
106
|
+
name="Call Queues",
|
|
107
|
+
list_url="/telephony/config/queues",
|
|
108
|
+
item_key="queues",
|
|
109
|
+
delete_url="/telephony/config/locations/{location_id}/queues/{id}",
|
|
110
|
+
supports_location_filter=True,
|
|
111
|
+
),
|
|
112
|
+
"paging_groups": ResourceType(
|
|
113
|
+
name="Paging Groups",
|
|
114
|
+
list_url="/telephony/config/paging",
|
|
115
|
+
item_key="locationPaging",
|
|
116
|
+
delete_url="/telephony/config/locations/{location_id}/paging/{id}",
|
|
117
|
+
supports_location_filter=True,
|
|
118
|
+
),
|
|
119
|
+
"call_parks": ResourceType(
|
|
120
|
+
name="Call Parks",
|
|
121
|
+
list_url="/telephony/config/locations/{location_id}/callParks",
|
|
122
|
+
item_key="callParks",
|
|
123
|
+
delete_url="/telephony/config/locations/{location_id}/callParks/{id}",
|
|
124
|
+
location_scoped_list=True,
|
|
125
|
+
),
|
|
126
|
+
"call_pickups": ResourceType(
|
|
127
|
+
name="Call Pickups",
|
|
128
|
+
list_url="/telephony/config/locations/{location_id}/callPickups",
|
|
129
|
+
item_key="callPickups",
|
|
130
|
+
delete_url="/telephony/config/locations/{location_id}/callPickups/{id}",
|
|
131
|
+
location_scoped_list=True,
|
|
132
|
+
),
|
|
133
|
+
"operating_modes": ResourceType(
|
|
134
|
+
name="Operating Modes",
|
|
135
|
+
list_url="/telephony/config/operatingModes",
|
|
136
|
+
item_key="operatingModes",
|
|
137
|
+
delete_url="/telephony/config/operatingModes/{id}",
|
|
138
|
+
),
|
|
139
|
+
"schedules": ResourceType(
|
|
140
|
+
name="Schedules",
|
|
141
|
+
list_url="/telephony/config/locations/{location_id}/schedules",
|
|
142
|
+
item_key="schedules",
|
|
143
|
+
delete_url="/telephony/config/locations/{location_id}/schedules/{type}/{id}",
|
|
144
|
+
location_scoped_list=True,
|
|
145
|
+
extra_delete_fields=["type"],
|
|
146
|
+
),
|
|
147
|
+
"virtual_lines": ResourceType(
|
|
148
|
+
name="Virtual Lines",
|
|
149
|
+
list_url="/telephony/config/virtualLines",
|
|
150
|
+
item_key="virtualLines",
|
|
151
|
+
delete_url="/telephony/config/virtualLines/{id}",
|
|
152
|
+
),
|
|
153
|
+
"devices": ResourceType(
|
|
154
|
+
name="Devices",
|
|
155
|
+
list_url="/devices",
|
|
156
|
+
item_key="items",
|
|
157
|
+
delete_url="/devices/{id}",
|
|
158
|
+
),
|
|
159
|
+
"workspaces": ResourceType(
|
|
160
|
+
name="Workspaces",
|
|
161
|
+
list_url="/workspaces",
|
|
162
|
+
item_key="items",
|
|
163
|
+
delete_url="/workspaces/{id}",
|
|
164
|
+
supports_location_filter=True,
|
|
165
|
+
),
|
|
166
|
+
"users": ResourceType(
|
|
167
|
+
name="Users",
|
|
168
|
+
list_url="/people",
|
|
169
|
+
item_key="items",
|
|
170
|
+
delete_url="/people/{id}",
|
|
171
|
+
name_field="displayName",
|
|
172
|
+
server_side_location_filter=True,
|
|
173
|
+
# /people?locationId=... requires callingData=true or the API returns
|
|
174
|
+
# "Calling flag not set" (error 25008 equivalent). Without this, scoped
|
|
175
|
+
# user listing silently returns nothing and users are never cleaned up.
|
|
176
|
+
extra_list_params={"callingData": "true"},
|
|
177
|
+
),
|
|
178
|
+
"numbers": ResourceType(
|
|
179
|
+
name="Numbers",
|
|
180
|
+
list_url="/telephony/config/numbers",
|
|
181
|
+
item_key="phoneNumbers",
|
|
182
|
+
delete_url="/telephony/config/locations/{location_id}/numbers",
|
|
183
|
+
),
|
|
184
|
+
"locations": ResourceType(
|
|
185
|
+
name="Locations",
|
|
186
|
+
list_url="/locations",
|
|
187
|
+
item_key="items",
|
|
188
|
+
delete_url="/locations/{id}",
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Deletion proceeds in this order (reverse of creation).
|
|
193
|
+
# Within each layer, all deletions run in parallel.
|
|
194
|
+
DELETION_LAYERS: list[list[str]] = [
|
|
195
|
+
["dial_plans"], # Layer 1: reference route lists/groups
|
|
196
|
+
["route_lists"], # Layer 2: reference route groups
|
|
197
|
+
["route_groups"], # Layer 3: reference trunks
|
|
198
|
+
["translation_patterns"], # Layer 4: org-level or location-level
|
|
199
|
+
["trunks"], # Layer 5: reference locations
|
|
200
|
+
["call_queues", "hunt_groups", "auto_attendants", "paging_groups", "call_parks", "call_pickups"], # Layer 6
|
|
201
|
+
["operating_modes", "schedules"], # Layer 7: were referenced by AAs
|
|
202
|
+
["virtual_lines"], # Layer 8
|
|
203
|
+
["devices"], # Layer 9
|
|
204
|
+
["workspaces"], # Layer 10: must delete before location detach attempt
|
|
205
|
+
["users"], # Layer 11: opt-in via --include-users
|
|
206
|
+
["numbers"], # Layer 12: remove numbers before location deletion
|
|
207
|
+
["locations"], # Layer 13: attempt calling detach first, then delete
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Inventory
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def list_resources(
|
|
216
|
+
api,
|
|
217
|
+
rt: ResourceType,
|
|
218
|
+
org_id: str | None,
|
|
219
|
+
location_ids: list[str] | None,
|
|
220
|
+
scope_filter: bool = True,
|
|
221
|
+
) -> list[dict]:
|
|
222
|
+
"""List all resources of a given type, respecting scope.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
scope_filter: When True, filter supports_location_filter resources by
|
|
226
|
+
location_ids. Set False for org-wide cleanup so resources without
|
|
227
|
+
a locationId are not silently excluded.
|
|
228
|
+
"""
|
|
229
|
+
params = {}
|
|
230
|
+
if org_id:
|
|
231
|
+
params["orgId"] = org_id
|
|
232
|
+
if rt.extra_list_params:
|
|
233
|
+
params.update(rt.extra_list_params)
|
|
234
|
+
|
|
235
|
+
if rt.location_scoped_list:
|
|
236
|
+
# Must iterate per location
|
|
237
|
+
if not location_ids:
|
|
238
|
+
return []
|
|
239
|
+
all_items = []
|
|
240
|
+
for loc_id in location_ids:
|
|
241
|
+
url = f"{BASE}{rt.list_url.format(location_id=loc_id)}"
|
|
242
|
+
try:
|
|
243
|
+
items = list(api.session.follow_pagination(
|
|
244
|
+
url=url, params=dict(params), item_key=rt.item_key,
|
|
245
|
+
))
|
|
246
|
+
# Tag each item with its location_id for delete
|
|
247
|
+
for item in items:
|
|
248
|
+
item.setdefault("locationId", loc_id)
|
|
249
|
+
all_items.extend(items)
|
|
250
|
+
except WebexError as e:
|
|
251
|
+
logger.warning("Failed to list %s in location %s: %s", rt.name, loc_id, e)
|
|
252
|
+
console.print(f"[yellow]⚠ Failed to list {rt.name} in location {loc_id}: {e} — inventory may be incomplete[/yellow]")
|
|
253
|
+
return all_items
|
|
254
|
+
|
|
255
|
+
if scope_filter and location_ids and rt.server_side_location_filter:
|
|
256
|
+
all_items = []
|
|
257
|
+
for loc_id in location_ids:
|
|
258
|
+
try:
|
|
259
|
+
loc_params = {**params, "locationId": loc_id}
|
|
260
|
+
items = list(api.session.follow_pagination(
|
|
261
|
+
url=f"{BASE}{rt.list_url}", params=loc_params,
|
|
262
|
+
item_key=rt.item_key,
|
|
263
|
+
))
|
|
264
|
+
for item in items:
|
|
265
|
+
item.setdefault("locationId", loc_id)
|
|
266
|
+
all_items.extend(items)
|
|
267
|
+
except WebexError as e:
|
|
268
|
+
logger.warning("Failed to list %s in location %s: %s", rt.name, loc_id, e)
|
|
269
|
+
console.print(f"[yellow]⚠ Failed to list {rt.name} in location {loc_id}: {e} — inventory may be incomplete[/yellow]")
|
|
270
|
+
return all_items
|
|
271
|
+
|
|
272
|
+
# Org-scoped list
|
|
273
|
+
url = f"{BASE}{rt.list_url}"
|
|
274
|
+
try:
|
|
275
|
+
items = list(api.session.follow_pagination(
|
|
276
|
+
url=url, params=params, item_key=rt.item_key,
|
|
277
|
+
))
|
|
278
|
+
except WebexError as e:
|
|
279
|
+
logger.warning("Failed to list %s: %s", rt.name, e)
|
|
280
|
+
console.print(f"[yellow]⚠ Failed to list {rt.name}: {e} — inventory may be incomplete[/yellow]")
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
# Filter by location scope if requested and items have locationId
|
|
284
|
+
if scope_filter and location_ids and rt.supports_location_filter:
|
|
285
|
+
loc_set = set(location_ids)
|
|
286
|
+
items = [i for i in items if i.get("locationId") in loc_set]
|
|
287
|
+
|
|
288
|
+
return items
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_inventory(
|
|
292
|
+
api,
|
|
293
|
+
org_id: str | None,
|
|
294
|
+
location_ids: list[str] | None,
|
|
295
|
+
include_users: bool,
|
|
296
|
+
include_locations: bool,
|
|
297
|
+
scope_filter: bool = True,
|
|
298
|
+
) -> dict[str, list[dict]]:
|
|
299
|
+
"""Build a full inventory of resources to delete."""
|
|
300
|
+
inventory: dict[str, list[dict]] = {}
|
|
301
|
+
|
|
302
|
+
for layer in DELETION_LAYERS:
|
|
303
|
+
for key in layer:
|
|
304
|
+
if key == "users" and not include_users:
|
|
305
|
+
continue
|
|
306
|
+
if key == "locations" and not include_locations:
|
|
307
|
+
continue
|
|
308
|
+
if key == "numbers":
|
|
309
|
+
continue # numbers populated separately by build_number_inventory
|
|
310
|
+
|
|
311
|
+
rt = RESOURCE_TYPES[key]
|
|
312
|
+
items = list_resources(api, rt, org_id, location_ids, scope_filter=scope_filter)
|
|
313
|
+
if key == "locations" and scope_filter and location_ids:
|
|
314
|
+
loc_set = set(location_ids)
|
|
315
|
+
items = [i for i in items if i.get("id") in loc_set]
|
|
316
|
+
if items:
|
|
317
|
+
inventory[key] = items
|
|
318
|
+
|
|
319
|
+
return inventory
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Deletion
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class DeleteResult:
|
|
328
|
+
"""Result of a single delete operation."""
|
|
329
|
+
resource_type: str
|
|
330
|
+
resource_id: str
|
|
331
|
+
resource_name: str
|
|
332
|
+
success: bool
|
|
333
|
+
error: str | None = None
|
|
334
|
+
# True when a failure was classified as a CCP-integrated PSTN backend
|
|
335
|
+
# gate (e.g. Webex async-releasing trunk references). These aren't real
|
|
336
|
+
# failures the operator can act on — the final summary reports them
|
|
337
|
+
# separately and the process exits 0.
|
|
338
|
+
ccp_blocked: bool = False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def delete_resource(
|
|
342
|
+
api,
|
|
343
|
+
rt: ResourceType,
|
|
344
|
+
item: dict,
|
|
345
|
+
org_id: str | None,
|
|
346
|
+
) -> DeleteResult:
|
|
347
|
+
"""Delete a single resource. Returns a DeleteResult."""
|
|
348
|
+
resource_id = item.get(rt.id_field, "unknown")
|
|
349
|
+
resource_name = item.get(rt.name_field, "unnamed")
|
|
350
|
+
|
|
351
|
+
# Build delete URL
|
|
352
|
+
fmt_kwargs = {"id": resource_id}
|
|
353
|
+
if "{location_id}" in rt.delete_url:
|
|
354
|
+
fmt_kwargs["location_id"] = item.get("locationId", "")
|
|
355
|
+
for field_name in rt.extra_delete_fields:
|
|
356
|
+
fmt_kwargs[field_name] = item.get(field_name, "")
|
|
357
|
+
|
|
358
|
+
url = f"{BASE}{rt.delete_url.format(**fmt_kwargs)}"
|
|
359
|
+
params = {}
|
|
360
|
+
if org_id:
|
|
361
|
+
params["orgId"] = org_id
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
api.session.rest_delete(url, params=params)
|
|
365
|
+
return DeleteResult(
|
|
366
|
+
resource_type=rt.name,
|
|
367
|
+
resource_id=resource_id,
|
|
368
|
+
resource_name=resource_name,
|
|
369
|
+
success=True,
|
|
370
|
+
)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
return DeleteResult(
|
|
373
|
+
resource_type=rt.name,
|
|
374
|
+
resource_id=resource_id,
|
|
375
|
+
resource_name=resource_name,
|
|
376
|
+
success=False,
|
|
377
|
+
error=str(e),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def execute_layer(
|
|
382
|
+
api,
|
|
383
|
+
layer_keys: list[str],
|
|
384
|
+
inventory: dict[str, list[dict]],
|
|
385
|
+
org_id: str | None,
|
|
386
|
+
max_concurrent: int = 5,
|
|
387
|
+
) -> list[DeleteResult]:
|
|
388
|
+
"""Execute all deletions in a layer in parallel."""
|
|
389
|
+
# Collect all (rt, item) pairs for this layer
|
|
390
|
+
work: list[tuple[ResourceType, dict]] = []
|
|
391
|
+
for key in layer_keys:
|
|
392
|
+
if key not in inventory:
|
|
393
|
+
continue
|
|
394
|
+
rt = RESOURCE_TYPES[key]
|
|
395
|
+
for item in inventory[key]:
|
|
396
|
+
work.append((rt, item))
|
|
397
|
+
|
|
398
|
+
if not work:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
results: list[DeleteResult] = []
|
|
402
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
|
403
|
+
futures = {
|
|
404
|
+
executor.submit(delete_resource, api, rt, item, org_id): (rt, item)
|
|
405
|
+
for rt, item in work
|
|
406
|
+
}
|
|
407
|
+
for future in as_completed(futures):
|
|
408
|
+
results.append(future.result())
|
|
409
|
+
|
|
410
|
+
return results
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Location-specific deletion
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def disable_location_calling(
|
|
418
|
+
api,
|
|
419
|
+
location_id: str,
|
|
420
|
+
org_id: str | None,
|
|
421
|
+
) -> DeleteResult:
|
|
422
|
+
"""Best-effort telephony detach before location deletion.
|
|
423
|
+
|
|
424
|
+
The DELETE against ``/telephony/config/locations/{id}`` is useful in
|
|
425
|
+
practice, but it is not a reliable public contract for disabling calling
|
|
426
|
+
on a location. Success here only means the request completed; location
|
|
427
|
+
delete can still fail on visible or backend-held dependencies.
|
|
428
|
+
"""
|
|
429
|
+
url = f"{BASE}/telephony/config/locations/{location_id}"
|
|
430
|
+
params = {}
|
|
431
|
+
if org_id:
|
|
432
|
+
params["orgId"] = org_id
|
|
433
|
+
try:
|
|
434
|
+
api.session.rest_delete(url, params=params)
|
|
435
|
+
return DeleteResult(
|
|
436
|
+
resource_type="Location Calling",
|
|
437
|
+
resource_id=location_id,
|
|
438
|
+
resource_name=f"disable-calling:{location_id}",
|
|
439
|
+
success=True,
|
|
440
|
+
)
|
|
441
|
+
except WebexError as e:
|
|
442
|
+
return DeleteResult(
|
|
443
|
+
resource_type="Location Calling",
|
|
444
|
+
resource_id=location_id,
|
|
445
|
+
resource_name=f"disable-calling:{location_id}",
|
|
446
|
+
success=False,
|
|
447
|
+
error=str(e),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _is_ccp_integrated_error(err: str) -> bool:
|
|
452
|
+
"""True if an error message carries the CCP-integrated PSTN signature.
|
|
453
|
+
|
|
454
|
+
Webex returns ``ERR.V.TRM.TMN60004`` (or a variant message referencing
|
|
455
|
+
"non-integrated CCP") whenever an API caller tries to delete a resource
|
|
456
|
+
whose lifecycle is owned by the Cisco Calling Plan backend. This signature
|
|
457
|
+
has been observed for number deletes; it can also bleed into downstream
|
|
458
|
+
409s on location delete when the backend is still releasing internal
|
|
459
|
+
trunk references asynchronously.
|
|
460
|
+
"""
|
|
461
|
+
if not err:
|
|
462
|
+
return False
|
|
463
|
+
if CCP_INTEGRATED_ERROR_CODE in err:
|
|
464
|
+
return True
|
|
465
|
+
lowered = err.lower()
|
|
466
|
+
return "non-integrated ccp" in lowered
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _is_ccp_backend_gate(
|
|
470
|
+
err: str, local_dependencies_clear: bool,
|
|
471
|
+
) -> bool:
|
|
472
|
+
"""Classify a location delete failure as the explicit CCP backend gate.
|
|
473
|
+
|
|
474
|
+
Generic 409 "being referenced" errors are not sufficient evidence on
|
|
475
|
+
their own. In practice they also occur when visible location blockers
|
|
476
|
+
remain, such as preserved users, phone numbers, or voice portal state.
|
|
477
|
+
Treat only the explicit CCP signature as authoritative.
|
|
478
|
+
"""
|
|
479
|
+
if not err:
|
|
480
|
+
return False
|
|
481
|
+
return _is_ccp_integrated_error(err)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _location_blockers(
|
|
485
|
+
api, location_id: str, org_id: str | None,
|
|
486
|
+
) -> list[str]:
|
|
487
|
+
"""Probe for visible dependencies at a location.
|
|
488
|
+
|
|
489
|
+
Returns a human-readable list of blockers still visible on the location.
|
|
490
|
+
This is best-effort and intentionally biased toward surfacing concrete
|
|
491
|
+
local blockers rather than inferring backend state from missing evidence.
|
|
492
|
+
"""
|
|
493
|
+
probe_order = [
|
|
494
|
+
"users",
|
|
495
|
+
"workspaces",
|
|
496
|
+
"devices",
|
|
497
|
+
"hunt groups",
|
|
498
|
+
"auto attendants",
|
|
499
|
+
"call queues",
|
|
500
|
+
"paging groups",
|
|
501
|
+
"call parks",
|
|
502
|
+
"call pickups",
|
|
503
|
+
"trunks",
|
|
504
|
+
"route groups",
|
|
505
|
+
"numbers",
|
|
506
|
+
"voice portal",
|
|
507
|
+
]
|
|
508
|
+
probes: list[tuple[str, dict, str]] = [
|
|
509
|
+
("/people", {"locationId": location_id, "callingData": "true"}, "users"),
|
|
510
|
+
("/workspaces", {"locationId": location_id}, "workspaces"),
|
|
511
|
+
("/devices", {"locationId": location_id}, "devices"),
|
|
512
|
+
("/telephony/config/huntGroups", {"locationId": location_id}, "hunt groups"),
|
|
513
|
+
("/telephony/config/autoAttendants", {"locationId": location_id}, "auto attendants"),
|
|
514
|
+
("/telephony/config/queues", {"locationId": location_id}, "call queues"),
|
|
515
|
+
("/telephony/config/paging", {"locationId": location_id}, "paging groups"),
|
|
516
|
+
(f"/telephony/config/locations/{location_id}/callParks", {}, "call parks"),
|
|
517
|
+
(f"/telephony/config/locations/{location_id}/callPickups", {}, "call pickups"),
|
|
518
|
+
("/telephony/config/premisePstn/trunks", {"locationId": location_id}, "trunks"),
|
|
519
|
+
("/telephony/config/premisePstn/routeGroups", {}, "route groups"),
|
|
520
|
+
]
|
|
521
|
+
blocker_details: dict[str, list[str]] = {}
|
|
522
|
+
params_base: dict = {}
|
|
523
|
+
if org_id:
|
|
524
|
+
params_base["orgId"] = org_id
|
|
525
|
+
|
|
526
|
+
def _record(label: str, detail: str | None = None) -> None:
|
|
527
|
+
blocker_details.setdefault(label, [])
|
|
528
|
+
if detail and detail not in blocker_details[label]:
|
|
529
|
+
blocker_details[label].append(detail)
|
|
530
|
+
|
|
531
|
+
def _has_real_items(path: str, values: list[dict]) -> bool:
|
|
532
|
+
"""Return True only when a list contains actual resources.
|
|
533
|
+
|
|
534
|
+
Some APIs return sentinel rows such as
|
|
535
|
+
``{"errors": {"peopleListLocationId": ...}}`` instead of an empty
|
|
536
|
+
list when a location filter matches nothing. These should not be
|
|
537
|
+
interpreted as visible blockers.
|
|
538
|
+
"""
|
|
539
|
+
if path.endswith("/people"):
|
|
540
|
+
return any(isinstance(item, dict) and item.get("id") for item in values)
|
|
541
|
+
return bool(values)
|
|
542
|
+
|
|
543
|
+
def _item_identifier(item: dict, prefer_email: bool = False) -> str | None:
|
|
544
|
+
if not isinstance(item, dict):
|
|
545
|
+
return None
|
|
546
|
+
parts: list[str] = []
|
|
547
|
+
if prefer_email and item.get("emails"):
|
|
548
|
+
email = item["emails"][0]
|
|
549
|
+
if isinstance(email, str) and email:
|
|
550
|
+
parts.append(email)
|
|
551
|
+
name = item.get("displayName") or item.get("name")
|
|
552
|
+
if isinstance(name, str) and name:
|
|
553
|
+
parts.append(name)
|
|
554
|
+
item_id = item.get("id")
|
|
555
|
+
if isinstance(item_id, str) and item_id:
|
|
556
|
+
parts.append(item_id)
|
|
557
|
+
return " / ".join(parts[:3]) if parts else None
|
|
558
|
+
|
|
559
|
+
def _collect_details(path: str, values: list[dict], label: str) -> None:
|
|
560
|
+
items = [item for item in values if isinstance(item, dict)]
|
|
561
|
+
if path.endswith("/people"):
|
|
562
|
+
real_people = [item for item in items if item.get("id")]
|
|
563
|
+
for person in real_people[:3]:
|
|
564
|
+
_record(label, _item_identifier(person, prefer_email=True))
|
|
565
|
+
if real_people:
|
|
566
|
+
_record(label)
|
|
567
|
+
return
|
|
568
|
+
if path.endswith("/routeGroups"):
|
|
569
|
+
matched = [
|
|
570
|
+
rg for rg in items
|
|
571
|
+
if (rg.get("location") or {}).get("id") == location_id
|
|
572
|
+
]
|
|
573
|
+
for route_group in matched[:3]:
|
|
574
|
+
_record(label, _item_identifier(route_group))
|
|
575
|
+
if matched:
|
|
576
|
+
_record(label)
|
|
577
|
+
return
|
|
578
|
+
for value in items[:3]:
|
|
579
|
+
_record(label, _item_identifier(value))
|
|
580
|
+
if items:
|
|
581
|
+
_record(label)
|
|
582
|
+
|
|
583
|
+
for path, extra, label in probes:
|
|
584
|
+
params = {**params_base, **extra}
|
|
585
|
+
try:
|
|
586
|
+
result = api.session.rest_get(f"{BASE}{path}", params=params)
|
|
587
|
+
except Exception:
|
|
588
|
+
continue
|
|
589
|
+
if not isinstance(result, dict):
|
|
590
|
+
continue
|
|
591
|
+
for values in result.values():
|
|
592
|
+
if isinstance(values, list) and _has_real_items(path, values):
|
|
593
|
+
_collect_details(path, values, label)
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
numbers = api.session.rest_get(
|
|
598
|
+
f"{BASE}/telephony/config/numbers",
|
|
599
|
+
params={**params_base, "locationId": location_id},
|
|
600
|
+
)
|
|
601
|
+
phone_numbers = numbers.get("phoneNumbers", []) if isinstance(numbers, dict) else []
|
|
602
|
+
if phone_numbers:
|
|
603
|
+
_record("numbers")
|
|
604
|
+
for number in phone_numbers[:5]:
|
|
605
|
+
number_label = (
|
|
606
|
+
number.get("phoneNumber")
|
|
607
|
+
or number.get("extension")
|
|
608
|
+
or number.get("esn")
|
|
609
|
+
or number.get("id")
|
|
610
|
+
)
|
|
611
|
+
owner = number.get("owner") or {}
|
|
612
|
+
owner_type = owner.get("type")
|
|
613
|
+
if owner_type == "VOICE_MESSAGING":
|
|
614
|
+
detail = f"{number_label} (voice portal)" if number_label else "voice portal number"
|
|
615
|
+
_record("voice portal", detail)
|
|
616
|
+
elif number_label:
|
|
617
|
+
_record("numbers", number_label)
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
voice_portal = api.session.rest_get(
|
|
623
|
+
f"{BASE}/telephony/config/locations/{location_id}/voicePortal",
|
|
624
|
+
params=params_base,
|
|
625
|
+
)
|
|
626
|
+
if isinstance(voice_portal, dict) and voice_portal.get("id"):
|
|
627
|
+
vp_name = voice_portal.get("name")
|
|
628
|
+
vp_id = voice_portal.get("id")
|
|
629
|
+
detail = " / ".join(part for part in (vp_name, vp_id) if part)
|
|
630
|
+
_record("voice portal", detail or None)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
def _format_details(label: str, details: list[str]) -> str:
|
|
635
|
+
if label == "users":
|
|
636
|
+
prefix = "preserved users"
|
|
637
|
+
else:
|
|
638
|
+
prefix = label
|
|
639
|
+
if details:
|
|
640
|
+
return f"{prefix} [{', '.join(details)}]"
|
|
641
|
+
return prefix
|
|
642
|
+
|
|
643
|
+
return [
|
|
644
|
+
_format_details(label, blocker_details[label])
|
|
645
|
+
for label in probe_order
|
|
646
|
+
if label in blocker_details
|
|
647
|
+
]
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _location_has_local_dependencies(
|
|
651
|
+
api, location_id: str, org_id: str | None,
|
|
652
|
+
) -> bool:
|
|
653
|
+
"""Backward-compatible boolean wrapper over the blocker probe."""
|
|
654
|
+
return bool(_location_blockers(api, location_id, org_id))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _extract_blocker(err: str) -> str | None:
|
|
658
|
+
"""Extract a blocking resource hint from a 409 error body if the API surfaces one."""
|
|
659
|
+
if not err:
|
|
660
|
+
return None
|
|
661
|
+
lowered = err.lower()
|
|
662
|
+
for kw in (
|
|
663
|
+
"device", "user", "workspace", "virtual line", "trunk",
|
|
664
|
+
"route group", "route list", "dial plan", "hunt group",
|
|
665
|
+
"call queue", "auto attendant", "number",
|
|
666
|
+
):
|
|
667
|
+
if kw in lowered:
|
|
668
|
+
return kw
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def delete_location(
|
|
673
|
+
api,
|
|
674
|
+
item: dict,
|
|
675
|
+
org_id: str | None,
|
|
676
|
+
max_attempts: int = LOCATION_DELETE_MAX_ATTEMPTS,
|
|
677
|
+
retry_sleep: int = LOCATION_DELETE_RETRY_SLEEP,
|
|
678
|
+
) -> DeleteResult:
|
|
679
|
+
"""Delete a location after the best-effort telephony detach attempt.
|
|
680
|
+
|
|
681
|
+
Retries up to ``max_attempts`` times with ``retry_sleep`` seconds between
|
|
682
|
+
attempts. The 90-second caller-side propagation wait after the detach
|
|
683
|
+
often isn't enough on larger orgs — the backend may still 409 for several
|
|
684
|
+
minutes. Each attempt emits a stdout line via ``click.echo`` (flushed)
|
|
685
|
+
so an observing parent process sees liveness and never has to hand-roll
|
|
686
|
+
an external polling loop.
|
|
687
|
+
"""
|
|
688
|
+
location_id = item.get("id", "")
|
|
689
|
+
name = item.get("name", "unnamed")
|
|
690
|
+
url = f"{BASE}/locations/{location_id}"
|
|
691
|
+
params = {}
|
|
692
|
+
if org_id:
|
|
693
|
+
params["orgId"] = org_id
|
|
694
|
+
|
|
695
|
+
last_error: str = ""
|
|
696
|
+
ccp_blocked = False
|
|
697
|
+
for attempt in range(1, max_attempts + 1):
|
|
698
|
+
try:
|
|
699
|
+
api.session.rest_delete(url, params=params)
|
|
700
|
+
click.echo(f"[location={name}] deleted", nl=True)
|
|
701
|
+
try:
|
|
702
|
+
click.get_text_stream("stdout").flush()
|
|
703
|
+
except Exception:
|
|
704
|
+
logger.debug("stdout flush failed — parent may lose liveness signal")
|
|
705
|
+
return DeleteResult(
|
|
706
|
+
resource_type="Locations",
|
|
707
|
+
resource_id=location_id,
|
|
708
|
+
resource_name=name,
|
|
709
|
+
success=True,
|
|
710
|
+
)
|
|
711
|
+
except WebexError as e:
|
|
712
|
+
last_error = str(e)
|
|
713
|
+
is_conflict = "409" in last_error or "conflict" in last_error.lower()
|
|
714
|
+
|
|
715
|
+
# Only the explicit CCP signature is authoritative enough to
|
|
716
|
+
# short-circuit retries. Generic "being referenced" conflicts can
|
|
717
|
+
# still be caused by visible blockers we should surface normally.
|
|
718
|
+
if _is_ccp_backend_gate(last_error, local_dependencies_clear=False):
|
|
719
|
+
ccp_blocked = True
|
|
720
|
+
break
|
|
721
|
+
|
|
722
|
+
if attempt < max_attempts and is_conflict:
|
|
723
|
+
click.echo(
|
|
724
|
+
f"[location={name}] attempt {attempt}/{max_attempts}: "
|
|
725
|
+
f"409 — retrying in {retry_sleep}s",
|
|
726
|
+
nl=True,
|
|
727
|
+
)
|
|
728
|
+
try:
|
|
729
|
+
click.get_text_stream("stdout").flush()
|
|
730
|
+
except Exception:
|
|
731
|
+
logger.debug("stdout flush failed — parent may lose liveness signal")
|
|
732
|
+
time.sleep(retry_sleep)
|
|
733
|
+
continue
|
|
734
|
+
# Non-409 error, or exhausted attempts: stop retrying.
|
|
735
|
+
break
|
|
736
|
+
|
|
737
|
+
if ccp_blocked:
|
|
738
|
+
final_msg = (
|
|
739
|
+
f"[location={name}] blocked by CCP-integrated PSTN backend "
|
|
740
|
+
f"cleanup — Webex backend has not yet released internal trunk "
|
|
741
|
+
f"references. This typically clears in 1-4 hours for dCloud/CCP "
|
|
742
|
+
f"orgs. Re-run `wxcli cleanup run` later (it is idempotent) to "
|
|
743
|
+
f"retry. No action you can take to speed this up."
|
|
744
|
+
)
|
|
745
|
+
click.echo(final_msg, nl=True)
|
|
746
|
+
try:
|
|
747
|
+
click.get_text_stream("stdout").flush()
|
|
748
|
+
except Exception:
|
|
749
|
+
logger.debug("stdout flush failed — parent may lose liveness signal")
|
|
750
|
+
return DeleteResult(
|
|
751
|
+
resource_type="Locations",
|
|
752
|
+
resource_id=location_id,
|
|
753
|
+
resource_name=name,
|
|
754
|
+
success=False,
|
|
755
|
+
error=final_msg,
|
|
756
|
+
ccp_blocked=True,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
blockers = _location_blockers(api, location_id, org_id)
|
|
760
|
+
blocker = _extract_blocker(last_error)
|
|
761
|
+
if blockers:
|
|
762
|
+
final_msg = (
|
|
763
|
+
f"delete failed after {max_attempts} attempt(s); "
|
|
764
|
+
f"visible blockers: {', '.join(blockers)}; {last_error}"
|
|
765
|
+
)
|
|
766
|
+
elif blocker:
|
|
767
|
+
final_msg = (
|
|
768
|
+
f"delete failed after {max_attempts} attempt(s); "
|
|
769
|
+
f"blocked by {blocker}: {last_error}"
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
final_msg = (
|
|
773
|
+
f"delete failed after {max_attempts} attempt(s): {last_error}"
|
|
774
|
+
)
|
|
775
|
+
click.echo(f"[location={name}] {final_msg}", nl=True)
|
|
776
|
+
try:
|
|
777
|
+
click.get_text_stream("stdout").flush()
|
|
778
|
+
except Exception:
|
|
779
|
+
logger.debug("stdout flush failed — parent may lose liveness signal")
|
|
780
|
+
return DeleteResult(
|
|
781
|
+
resource_type="Locations",
|
|
782
|
+
resource_id=location_id,
|
|
783
|
+
resource_name=name,
|
|
784
|
+
success=False,
|
|
785
|
+
error=final_msg,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ---------------------------------------------------------------------------
|
|
790
|
+
# Number removal
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
def list_location_numbers(
|
|
794
|
+
api,
|
|
795
|
+
location_id: str,
|
|
796
|
+
org_id: str | None,
|
|
797
|
+
) -> list[dict]:
|
|
798
|
+
"""List phone numbers assigned to a location."""
|
|
799
|
+
url = f"{BASE}/telephony/config/numbers"
|
|
800
|
+
params = {"locationId": location_id}
|
|
801
|
+
if org_id:
|
|
802
|
+
params["orgId"] = org_id
|
|
803
|
+
try:
|
|
804
|
+
result = api.session.rest_get(url, params=params)
|
|
805
|
+
if isinstance(result, dict):
|
|
806
|
+
return result.get("phoneNumbers", [])
|
|
807
|
+
return result if isinstance(result, list) else []
|
|
808
|
+
except WebexError as e:
|
|
809
|
+
logger.warning("Failed to list numbers for location %s: %s", location_id, e)
|
|
810
|
+
return []
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def delete_location_numbers(
|
|
814
|
+
api,
|
|
815
|
+
location_id: str,
|
|
816
|
+
phone_numbers: list[str],
|
|
817
|
+
org_id: str | None,
|
|
818
|
+
) -> list[DeleteResult]:
|
|
819
|
+
"""Delete phone numbers from a location in batches of 5 (API limit).
|
|
820
|
+
|
|
821
|
+
If 3 consecutive batch deletes fail at the same location (Issue #14),
|
|
822
|
+
stop iterating for that location and surface a warning. Any remaining
|
|
823
|
+
batches are marked as failures with a short "aborted" marker so the
|
|
824
|
+
final summary reflects what wasn't attempted.
|
|
825
|
+
"""
|
|
826
|
+
results: list[DeleteResult] = []
|
|
827
|
+
url = f"{BASE}/telephony/config/locations/{location_id}/numbers"
|
|
828
|
+
params = {}
|
|
829
|
+
if org_id:
|
|
830
|
+
params["orgId"] = org_id
|
|
831
|
+
|
|
832
|
+
CONSECUTIVE_FAILURE_LIMIT = 3
|
|
833
|
+
consecutive_failures = 0
|
|
834
|
+
aborted_at: int | None = None
|
|
835
|
+
|
|
836
|
+
# API allows max 5 numbers per request
|
|
837
|
+
total = len(phone_numbers)
|
|
838
|
+
for i in range(0, total, 5):
|
|
839
|
+
batch = phone_numbers[i:i + 5]
|
|
840
|
+
try:
|
|
841
|
+
api.session.rest_delete(url, params=params, json={"phoneNumbers": batch})
|
|
842
|
+
consecutive_failures = 0
|
|
843
|
+
for num in batch:
|
|
844
|
+
results.append(DeleteResult(
|
|
845
|
+
resource_type="Numbers",
|
|
846
|
+
resource_id=num,
|
|
847
|
+
resource_name=num,
|
|
848
|
+
success=True,
|
|
849
|
+
))
|
|
850
|
+
except WebexError as e:
|
|
851
|
+
err_str = str(e)
|
|
852
|
+
# CCP-integrated PSTN: number lifecycle is managed via the PSTN
|
|
853
|
+
# portal, not the API. Log and skip — don't treat as a failure.
|
|
854
|
+
if _is_ccp_integrated_error(err_str):
|
|
855
|
+
consecutive_failures = 0
|
|
856
|
+
for num in batch:
|
|
857
|
+
click.echo(
|
|
858
|
+
f"[number={num}] skipped — CCP-integrated, "
|
|
859
|
+
f"managed via PSTN portal",
|
|
860
|
+
nl=True,
|
|
861
|
+
)
|
|
862
|
+
try:
|
|
863
|
+
click.get_text_stream("stdout").flush()
|
|
864
|
+
except Exception:
|
|
865
|
+
logger.debug("stdout flush failed — parent may lose liveness signal")
|
|
866
|
+
results.append(DeleteResult(
|
|
867
|
+
resource_type="Numbers",
|
|
868
|
+
resource_id=num,
|
|
869
|
+
resource_name=num,
|
|
870
|
+
success=True,
|
|
871
|
+
error=None,
|
|
872
|
+
ccp_blocked=True,
|
|
873
|
+
))
|
|
874
|
+
continue
|
|
875
|
+
consecutive_failures += 1
|
|
876
|
+
for num in batch:
|
|
877
|
+
results.append(DeleteResult(
|
|
878
|
+
resource_type="Numbers",
|
|
879
|
+
resource_id=num,
|
|
880
|
+
resource_name=num,
|
|
881
|
+
success=False,
|
|
882
|
+
error=err_str,
|
|
883
|
+
))
|
|
884
|
+
if consecutive_failures >= CONSECUTIVE_FAILURE_LIMIT:
|
|
885
|
+
aborted_at = i + len(batch)
|
|
886
|
+
click.secho(
|
|
887
|
+
f"⚠️ Location {location_id[:12]}...: "
|
|
888
|
+
f"{CONSECUTIVE_FAILURE_LIMIT} consecutive number-delete "
|
|
889
|
+
f"batch failures — aborting number cleanup for this "
|
|
890
|
+
f"location",
|
|
891
|
+
fg="yellow",
|
|
892
|
+
err=False,
|
|
893
|
+
)
|
|
894
|
+
break
|
|
895
|
+
|
|
896
|
+
if aborted_at is not None:
|
|
897
|
+
for num in phone_numbers[aborted_at:]:
|
|
898
|
+
results.append(DeleteResult(
|
|
899
|
+
resource_type="Numbers",
|
|
900
|
+
resource_id=num,
|
|
901
|
+
resource_name=num,
|
|
902
|
+
success=False,
|
|
903
|
+
error="aborted: 3 consecutive batch failures at this location",
|
|
904
|
+
))
|
|
905
|
+
|
|
906
|
+
return results
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def build_number_inventory(
|
|
910
|
+
api,
|
|
911
|
+
location_ids: list[str],
|
|
912
|
+
org_id: str | None,
|
|
913
|
+
skipped_ext_only: list[str] | None = None,
|
|
914
|
+
) -> dict[str, list[str]]:
|
|
915
|
+
"""Build a map of location_id -> [phone_numbers] for cleanup.
|
|
916
|
+
|
|
917
|
+
Returns only locations that have numbers to remove.
|
|
918
|
+
|
|
919
|
+
If ``skipped_ext_only`` is provided, each extension-only record encountered
|
|
920
|
+
is appended to it as a "loc_id: owner" entry so the caller can list the
|
|
921
|
+
skips in the dry-run/summary output (Issue #1).
|
|
922
|
+
"""
|
|
923
|
+
inventory: dict[str, list[str]] = {}
|
|
924
|
+
for loc_id in location_ids:
|
|
925
|
+
numbers = list_location_numbers(api, loc_id, org_id)
|
|
926
|
+
phone_nums = []
|
|
927
|
+
ext_only = 0
|
|
928
|
+
for n in numbers:
|
|
929
|
+
# Extension-only records have no phoneNumber field — they are
|
|
930
|
+
# extension owners (person/workspace/virtual line), not PSTN
|
|
931
|
+
# number resources, and get released when the owner is deleted.
|
|
932
|
+
if not n.get("phoneNumber"):
|
|
933
|
+
ext_only += 1
|
|
934
|
+
if skipped_ext_only is not None:
|
|
935
|
+
owner = n.get("owner") or {}
|
|
936
|
+
# Best-effort human label: displayName → firstName/lastName
|
|
937
|
+
# → owner id → extension.
|
|
938
|
+
label = (
|
|
939
|
+
owner.get("displayName")
|
|
940
|
+
or " ".join(
|
|
941
|
+
part for part in (
|
|
942
|
+
owner.get("firstName"), owner.get("lastName"),
|
|
943
|
+
) if part
|
|
944
|
+
).strip()
|
|
945
|
+
or owner.get("id")
|
|
946
|
+
or n.get("extension")
|
|
947
|
+
or "(unknown)"
|
|
948
|
+
)
|
|
949
|
+
skipped_ext_only.append(f"{loc_id[:12]}...: {label}")
|
|
950
|
+
continue
|
|
951
|
+
if n.get("mainNumber") is True:
|
|
952
|
+
continue
|
|
953
|
+
phone_nums.append(n["phoneNumber"])
|
|
954
|
+
if ext_only:
|
|
955
|
+
logger.info(
|
|
956
|
+
"location %s: skipped %d extension-only record(s) "
|
|
957
|
+
"(extension owners, not phone numbers)",
|
|
958
|
+
loc_id, ext_only,
|
|
959
|
+
)
|
|
960
|
+
if phone_nums:
|
|
961
|
+
inventory[loc_id] = phone_nums
|
|
962
|
+
return inventory
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
# ---------------------------------------------------------------------------
|
|
966
|
+
# Formatters
|
|
967
|
+
# ---------------------------------------------------------------------------
|
|
968
|
+
|
|
969
|
+
def format_dry_run(inventory: dict[str, list[dict]]) -> str:
|
|
970
|
+
"""Format the inventory as a dry-run report."""
|
|
971
|
+
lines = ["", "=== DRY RUN — Resources that would be deleted ===", ""]
|
|
972
|
+
total = 0
|
|
973
|
+
for layer in DELETION_LAYERS:
|
|
974
|
+
for key in layer:
|
|
975
|
+
if key not in inventory:
|
|
976
|
+
continue
|
|
977
|
+
items = inventory[key]
|
|
978
|
+
rt = RESOURCE_TYPES[key]
|
|
979
|
+
lines.append(f" {rt.name:<25s} {len(items):>4d}")
|
|
980
|
+
for item in items[:5]:
|
|
981
|
+
name = item.get(rt.name_field, item.get("displayName", "unnamed"))
|
|
982
|
+
rid = item.get(rt.id_field, "?")
|
|
983
|
+
lines.append(f" - {name} ({rid[:12]}...)")
|
|
984
|
+
if len(items) > 5:
|
|
985
|
+
lines.append(f" ... and {len(items) - 5} more")
|
|
986
|
+
total += len(items)
|
|
987
|
+
|
|
988
|
+
lines.append("")
|
|
989
|
+
lines.append(f" {'TOTAL':<25s} {total:>4d}")
|
|
990
|
+
lines.append("")
|
|
991
|
+
return "\n".join(lines)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
# ---------------------------------------------------------------------------
|
|
995
|
+
# CLI Command
|
|
996
|
+
# ---------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
def _resolve_location_ids(
|
|
999
|
+
api, scope: str | None, org_id: str | None,
|
|
1000
|
+
unresolved: list[str] | None = None,
|
|
1001
|
+
) -> list[str] | None:
|
|
1002
|
+
"""Resolve --scope names/IDs to location IDs. Returns None for all.
|
|
1003
|
+
|
|
1004
|
+
If ``unresolved`` is provided, any scope tokens that don't match a
|
|
1005
|
+
location name or ID are appended to it (Issue #2). The caller is
|
|
1006
|
+
responsible for surfacing them in the output before the deletion
|
|
1007
|
+
summary so the operator has a chance to Ctrl+C.
|
|
1008
|
+
"""
|
|
1009
|
+
if not scope:
|
|
1010
|
+
return None
|
|
1011
|
+
|
|
1012
|
+
# Fetch all locations
|
|
1013
|
+
params = {}
|
|
1014
|
+
if org_id:
|
|
1015
|
+
params["orgId"] = org_id
|
|
1016
|
+
all_locs = list(api.session.follow_pagination(
|
|
1017
|
+
url=f"{BASE}/locations", params=params, item_key="items",
|
|
1018
|
+
))
|
|
1019
|
+
|
|
1020
|
+
# Build name->id and id->id maps
|
|
1021
|
+
name_map = {loc["name"]: loc["id"] for loc in all_locs}
|
|
1022
|
+
id_set = {loc["id"] for loc in all_locs}
|
|
1023
|
+
|
|
1024
|
+
requested = [s.strip() for s in scope.split(",")]
|
|
1025
|
+
resolved = []
|
|
1026
|
+
for r in requested:
|
|
1027
|
+
if r in id_set:
|
|
1028
|
+
resolved.append(r)
|
|
1029
|
+
elif r in name_map:
|
|
1030
|
+
resolved.append(name_map[r])
|
|
1031
|
+
else:
|
|
1032
|
+
if unresolved is not None:
|
|
1033
|
+
unresolved.append(r)
|
|
1034
|
+
|
|
1035
|
+
if not resolved:
|
|
1036
|
+
console.print("[red]No valid locations found for the given scope.[/red]")
|
|
1037
|
+
if unresolved:
|
|
1038
|
+
console.print(
|
|
1039
|
+
f"[yellow]Unresolved names: {', '.join(unresolved)}[/yellow]"
|
|
1040
|
+
)
|
|
1041
|
+
raise typer.Exit(1)
|
|
1042
|
+
|
|
1043
|
+
return resolved
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@app.command("run")
|
|
1047
|
+
def cleanup_run(
|
|
1048
|
+
scope: str = typer.Option(
|
|
1049
|
+
None, "--scope",
|
|
1050
|
+
help="Comma-separated location names or IDs to limit cleanup scope.",
|
|
1051
|
+
),
|
|
1052
|
+
all_resources: bool = typer.Option(
|
|
1053
|
+
False, "--all",
|
|
1054
|
+
help="Clean up the entire org (required if --scope not given).",
|
|
1055
|
+
),
|
|
1056
|
+
include_users: bool = typer.Option(
|
|
1057
|
+
False, "--include-users",
|
|
1058
|
+
help="Also delete users (destructive — disabled by default).",
|
|
1059
|
+
),
|
|
1060
|
+
include_locations: bool = typer.Option(
|
|
1061
|
+
False, "--include-locations",
|
|
1062
|
+
help="Also delete locations (destructive — disabled by default).",
|
|
1063
|
+
),
|
|
1064
|
+
exclude_user_domains: str = typer.Option(
|
|
1065
|
+
None, "--exclude-user-domains",
|
|
1066
|
+
help="Comma-separated email domains to exclude from user deletion (e.g. 'wbx.ai,corp.com').",
|
|
1067
|
+
),
|
|
1068
|
+
dry_run: bool = typer.Option(
|
|
1069
|
+
False, "--dry-run",
|
|
1070
|
+
help="Show what would be deleted without actually deleting.",
|
|
1071
|
+
),
|
|
1072
|
+
max_concurrent: int = typer.Option(
|
|
1073
|
+
5, "--max-concurrent",
|
|
1074
|
+
help="Max parallel deletions per layer.",
|
|
1075
|
+
),
|
|
1076
|
+
force: bool = typer.Option(
|
|
1077
|
+
False, "--force",
|
|
1078
|
+
help="Skip confirmation prompt.",
|
|
1079
|
+
),
|
|
1080
|
+
debug: bool = typer.Option(False, "--debug"),
|
|
1081
|
+
):
|
|
1082
|
+
"""Delete Webex Calling resources in dependency-safe order.
|
|
1083
|
+
|
|
1084
|
+
Examples:
|
|
1085
|
+
wxcli cleanup run --scope "HQ,Branch" --dry-run
|
|
1086
|
+
wxcli cleanup run --scope "HQ" --include-users --force
|
|
1087
|
+
wxcli cleanup run --all --include-locations --force
|
|
1088
|
+
wxcli cleanup run --all --include-users --exclude-user-domains "wbx.ai,corp.com"
|
|
1089
|
+
"""
|
|
1090
|
+
if not scope and not all_resources:
|
|
1091
|
+
console.print(
|
|
1092
|
+
"[red]Must specify --scope or --all.[/red]\n"
|
|
1093
|
+
"Use --dry-run to preview what would be deleted."
|
|
1094
|
+
)
|
|
1095
|
+
raise typer.Exit(1)
|
|
1096
|
+
|
|
1097
|
+
api = get_api(debug=debug)
|
|
1098
|
+
org_id = get_org_id()
|
|
1099
|
+
|
|
1100
|
+
# Resolve location scope
|
|
1101
|
+
console.print("[bold]Resolving scope...[/bold]")
|
|
1102
|
+
unresolved_scope: list[str] = []
|
|
1103
|
+
location_ids = _resolve_location_ids(
|
|
1104
|
+
api, scope, org_id, unresolved=unresolved_scope,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# Issue #2: surface unresolved --scope tokens BEFORE inventory/deletion
|
|
1108
|
+
# so the operator has a chance to Ctrl+C rather than finding them buried
|
|
1109
|
+
# in scrollback after cleanup runs.
|
|
1110
|
+
if unresolved_scope:
|
|
1111
|
+
click.secho(
|
|
1112
|
+
f"⚠️ Unresolved location names (will be ignored): "
|
|
1113
|
+
f"{', '.join(unresolved_scope)}",
|
|
1114
|
+
fg="yellow",
|
|
1115
|
+
err=False,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
if location_ids:
|
|
1119
|
+
console.print(f" Scoped to {len(location_ids)} location(s)")
|
|
1120
|
+
else:
|
|
1121
|
+
console.print(" Org-wide cleanup")
|
|
1122
|
+
|
|
1123
|
+
# For location-scoped listing, we always need location IDs
|
|
1124
|
+
# If --all and no scope, fetch all locations for per-location listing
|
|
1125
|
+
if location_ids is None:
|
|
1126
|
+
params = {}
|
|
1127
|
+
if org_id:
|
|
1128
|
+
params["orgId"] = org_id
|
|
1129
|
+
all_locs = list(api.session.follow_pagination(
|
|
1130
|
+
url=f"{BASE}/locations", params=params, item_key="items",
|
|
1131
|
+
))
|
|
1132
|
+
all_location_ids = [loc["id"] for loc in all_locs]
|
|
1133
|
+
else:
|
|
1134
|
+
all_location_ids = location_ids
|
|
1135
|
+
|
|
1136
|
+
# Build inventory — always pass all_location_ids so location-scoped types get enumerated
|
|
1137
|
+
# scope_filter=False for --all mode: don't exclude resources missing locationId
|
|
1138
|
+
is_scoped = scope is not None
|
|
1139
|
+
console.print("[bold]Building inventory...[/bold]")
|
|
1140
|
+
inventory = build_inventory(
|
|
1141
|
+
api, org_id, location_ids=all_location_ids,
|
|
1142
|
+
include_users=include_users, include_locations=include_locations,
|
|
1143
|
+
scope_filter=is_scoped,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
# Filter users by excluded domains
|
|
1147
|
+
if exclude_user_domains and "users" in inventory:
|
|
1148
|
+
excluded = {d.strip().lower() for d in exclude_user_domains.split(",")}
|
|
1149
|
+
before = len(inventory["users"])
|
|
1150
|
+
|
|
1151
|
+
def _user_matches_excluded(user: dict) -> bool:
|
|
1152
|
+
# People API returns emails as a list
|
|
1153
|
+
emails = user.get("emails", [])
|
|
1154
|
+
if not emails:
|
|
1155
|
+
return False
|
|
1156
|
+
primary = emails[0].lower() if isinstance(emails, list) else str(emails).lower()
|
|
1157
|
+
domain = primary.split("@")[-1]
|
|
1158
|
+
return any(domain == d or domain.endswith(f".{d}") for d in excluded)
|
|
1159
|
+
|
|
1160
|
+
inventory["users"] = [u for u in inventory["users"] if not _user_matches_excluded(u)]
|
|
1161
|
+
filtered = before - len(inventory["users"])
|
|
1162
|
+
# Issue #3: always print the exclusion notice when the flag is set so
|
|
1163
|
+
# the operator sees which domains are being kept and how many users
|
|
1164
|
+
# that covers — even when zero matched (e.g. a typo in the domain list).
|
|
1165
|
+
click.echo(
|
|
1166
|
+
f"Excluding users from domains: {', '.join(sorted(excluded))} "
|
|
1167
|
+
f"({filtered} user{'s' if filtered != 1 else ''} excluded)"
|
|
1168
|
+
)
|
|
1169
|
+
if not inventory["users"]:
|
|
1170
|
+
del inventory["users"]
|
|
1171
|
+
elif exclude_user_domains:
|
|
1172
|
+
# Flag set but no users to filter (e.g. --include-users not passed,
|
|
1173
|
+
# or inventory produced zero users). Still surface it so the operator
|
|
1174
|
+
# doesn't wonder whether the flag was honored.
|
|
1175
|
+
excluded = {d.strip().lower() for d in exclude_user_domains.split(",")}
|
|
1176
|
+
click.echo(
|
|
1177
|
+
f"Excluding users from domains: {', '.join(sorted(excluded))} "
|
|
1178
|
+
f"(0 users excluded)"
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
# Build number inventory for locations (needed for Layer 12)
|
|
1182
|
+
number_inventory: dict[str, list[str]] = {}
|
|
1183
|
+
skipped_ext_only: list[str] = []
|
|
1184
|
+
if include_locations:
|
|
1185
|
+
console.print("[bold]Checking for phone numbers on locations...[/bold]")
|
|
1186
|
+
number_inventory = build_number_inventory(
|
|
1187
|
+
api, all_location_ids, org_id,
|
|
1188
|
+
skipped_ext_only=skipped_ext_only,
|
|
1189
|
+
)
|
|
1190
|
+
total_nums = sum(len(v) for v in number_inventory.values())
|
|
1191
|
+
if total_nums:
|
|
1192
|
+
console.print(f" Found {total_nums} number(s) across {len(number_inventory)} location(s)")
|
|
1193
|
+
# Add to inventory for dry-run display
|
|
1194
|
+
inventory["numbers"] = [
|
|
1195
|
+
{"id": num, "name": num, "locationId": loc_id}
|
|
1196
|
+
for loc_id, nums in number_inventory.items()
|
|
1197
|
+
for num in nums
|
|
1198
|
+
]
|
|
1199
|
+
|
|
1200
|
+
# Issue #1: surface extension-only records that were intentionally skipped
|
|
1201
|
+
# (extension owners, not PSTN numbers — they get released when the owner
|
|
1202
|
+
# is deleted). Print BEFORE the deletion summary/dry-run so the operator
|
|
1203
|
+
# can see them in the same pane where the totals live.
|
|
1204
|
+
if skipped_ext_only:
|
|
1205
|
+
click.echo(
|
|
1206
|
+
f"\nSkipped {len(skipped_ext_only)} extension-only record(s):"
|
|
1207
|
+
)
|
|
1208
|
+
for entry in skipped_ext_only:
|
|
1209
|
+
click.echo(f" - {entry}")
|
|
1210
|
+
|
|
1211
|
+
if not inventory:
|
|
1212
|
+
console.print("[green]Nothing to delete — org is clean.[/green]")
|
|
1213
|
+
return
|
|
1214
|
+
|
|
1215
|
+
# Dry run
|
|
1216
|
+
console.print(format_dry_run(inventory))
|
|
1217
|
+
|
|
1218
|
+
if dry_run:
|
|
1219
|
+
console.print("[yellow]Dry run complete. No resources were deleted.[/yellow]")
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
# Confirmation
|
|
1223
|
+
total = sum(len(v) for v in inventory.values())
|
|
1224
|
+
if not force:
|
|
1225
|
+
typer.confirm(
|
|
1226
|
+
f"\nDelete {total} resources? This cannot be undone",
|
|
1227
|
+
abort=True,
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
# Execute layer by layer
|
|
1231
|
+
all_results: list[DeleteResult] = []
|
|
1232
|
+
for i, layer_keys in enumerate(DELETION_LAYERS):
|
|
1233
|
+
# Check if this layer has any work
|
|
1234
|
+
has_work = any(k in inventory for k in layer_keys)
|
|
1235
|
+
if not has_work:
|
|
1236
|
+
continue
|
|
1237
|
+
|
|
1238
|
+
# Skip users/locations/numbers layers if not included
|
|
1239
|
+
if "users" in layer_keys and not include_users:
|
|
1240
|
+
continue
|
|
1241
|
+
if "locations" in layer_keys and not include_locations:
|
|
1242
|
+
continue
|
|
1243
|
+
if "numbers" in layer_keys and not include_locations:
|
|
1244
|
+
continue
|
|
1245
|
+
|
|
1246
|
+
layer_names = [RESOURCE_TYPES[k].name for k in layer_keys if k in inventory]
|
|
1247
|
+
console.print(f"\n[bold]Layer {i + 1}:[/bold] {', '.join(layer_names)}")
|
|
1248
|
+
|
|
1249
|
+
# Special handling for numbers: delete per-location with batched body
|
|
1250
|
+
if "numbers" in layer_keys and number_inventory:
|
|
1251
|
+
for loc_id, phone_nums in number_inventory.items():
|
|
1252
|
+
console.print(f" Removing {len(phone_nums)} number(s) from location {loc_id[:12]}...")
|
|
1253
|
+
num_results = delete_location_numbers(api, loc_id, phone_nums, org_id)
|
|
1254
|
+
all_results.extend(num_results)
|
|
1255
|
+
ok = sum(1 for r in num_results if r.success)
|
|
1256
|
+
fail = sum(1 for r in num_results if not r.success)
|
|
1257
|
+
console.print(f" {ok} removed, {fail} failed")
|
|
1258
|
+
continue
|
|
1259
|
+
|
|
1260
|
+
# Special handling for locations: best-effort telephony detach first,
|
|
1261
|
+
# then wait, then delete.
|
|
1262
|
+
if "locations" in layer_keys and "locations" in inventory:
|
|
1263
|
+
loc_items = inventory["locations"]
|
|
1264
|
+
# Map location_id -> name for failure messages (Issue #11).
|
|
1265
|
+
loc_names = {item["id"]: item.get("name", item["id"]) for item in loc_items}
|
|
1266
|
+
# Phase 1: best-effort telephony detach. This endpoint is not a
|
|
1267
|
+
# reliable public disable-calling contract, so avoid overstating
|
|
1268
|
+
# what success means in operator-facing output.
|
|
1269
|
+
console.print(" Attempting telephony detach on locations...")
|
|
1270
|
+
disable_results = []
|
|
1271
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
|
1272
|
+
futures = {
|
|
1273
|
+
executor.submit(
|
|
1274
|
+
disable_location_calling, api, item["id"], org_id,
|
|
1275
|
+
): item
|
|
1276
|
+
for item in loc_items
|
|
1277
|
+
}
|
|
1278
|
+
for future in as_completed(futures):
|
|
1279
|
+
disable_results.append(future.result())
|
|
1280
|
+
|
|
1281
|
+
disabled_ok = [r for r in disable_results if r.success]
|
|
1282
|
+
disabled_failed = [r for r in disable_results if not r.success]
|
|
1283
|
+
console.print(
|
|
1284
|
+
f" Telephony detach request accepted for "
|
|
1285
|
+
f"{len(disabled_ok)}/{len(loc_items)} locations"
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
# Partition the inventory into two delete cohorts by disable outcome
|
|
1289
|
+
# (Issue #11 / Finding #6). Locations where the detach request
|
|
1290
|
+
# failed do NOT
|
|
1291
|
+
# pay the 90-second propagation wait — they proceed to delete
|
|
1292
|
+
# immediately. Locations where it succeeded wait 90s (the calling
|
|
1293
|
+
# subsystem propagation window) before the delete phase.
|
|
1294
|
+
succeeded_ids = {r.resource_id for r in disabled_ok}
|
|
1295
|
+
items_disable_ok = [it for it in loc_items if it["id"] in succeeded_ids]
|
|
1296
|
+
items_disable_failed = [
|
|
1297
|
+
it for it in loc_items if it["id"] not in succeeded_ids
|
|
1298
|
+
]
|
|
1299
|
+
|
|
1300
|
+
# Issue #11: surface each detach-request failure explicitly. Delete will
|
|
1301
|
+
# still be attempted (likely 409), but the existing
|
|
1302
|
+
# delete_location error-handling catches that and the caller
|
|
1303
|
+
# can re-run cleanup idempotently.
|
|
1304
|
+
for r in disabled_failed:
|
|
1305
|
+
name = loc_names.get(r.resource_id, r.resource_id)
|
|
1306
|
+
click.secho(
|
|
1307
|
+
f"⚠️ Location {name}: telephony detach request failed — "
|
|
1308
|
+
f"skipping 90s wait; will attempt delete anyway",
|
|
1309
|
+
fg="yellow",
|
|
1310
|
+
err=False,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
total_locs = len(loc_items)
|
|
1314
|
+
layer_num = i + 1
|
|
1315
|
+
console.print(
|
|
1316
|
+
f" Deleting locations (up to "
|
|
1317
|
+
f"{LOCATION_DELETE_MAX_ATTEMPTS} attempts × "
|
|
1318
|
+
f"{LOCATION_DELETE_RETRY_SLEEP}s per location)..."
|
|
1319
|
+
)
|
|
1320
|
+
console.print(
|
|
1321
|
+
f" Layer {layer_num} (location): 0/{total_locs} deleted, "
|
|
1322
|
+
f"0 retrying, {total_locs} waiting"
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
deleted = 0
|
|
1326
|
+
failed = 0
|
|
1327
|
+
completed = 0
|
|
1328
|
+
loc_results: list[DeleteResult] = []
|
|
1329
|
+
|
|
1330
|
+
def _delete_cohort(cohort_items: list[dict]) -> None:
|
|
1331
|
+
nonlocal deleted, failed, completed
|
|
1332
|
+
if not cohort_items:
|
|
1333
|
+
return
|
|
1334
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
|
1335
|
+
futures = {
|
|
1336
|
+
executor.submit(delete_location, api, item, org_id): item
|
|
1337
|
+
for item in cohort_items
|
|
1338
|
+
}
|
|
1339
|
+
for future in as_completed(futures):
|
|
1340
|
+
res = future.result()
|
|
1341
|
+
loc_results.append(res)
|
|
1342
|
+
all_results.append(res)
|
|
1343
|
+
completed += 1
|
|
1344
|
+
if res.success:
|
|
1345
|
+
deleted += 1
|
|
1346
|
+
else:
|
|
1347
|
+
failed += 1
|
|
1348
|
+
# The remaining in-flight futures are the ones still
|
|
1349
|
+
# looping through retry attempts; the rest are
|
|
1350
|
+
# "waiting" in the executor queue (only meaningful
|
|
1351
|
+
# when max_concurrent < total_locs).
|
|
1352
|
+
in_flight = min(
|
|
1353
|
+
max_concurrent, total_locs - completed,
|
|
1354
|
+
)
|
|
1355
|
+
waiting = max(0, total_locs - completed - in_flight)
|
|
1356
|
+
console.print(
|
|
1357
|
+
f" Layer {layer_num} (location): "
|
|
1358
|
+
f"{deleted}/{total_locs} deleted, "
|
|
1359
|
+
f"{in_flight} retrying, {waiting} waiting"
|
|
1360
|
+
)
|
|
1361
|
+
|
|
1362
|
+
# Phase 3a: delete locations where disable FAILED — no 90s wait.
|
|
1363
|
+
# These are expected to 409 on delete (calling still attached),
|
|
1364
|
+
# but we try anyway so a re-run after the operator fixes the
|
|
1365
|
+
# root cause can skip straight to success.
|
|
1366
|
+
_delete_cohort(items_disable_failed)
|
|
1367
|
+
|
|
1368
|
+
# Phase 2 (deferred): wait 90s only if any disables actually
|
|
1369
|
+
# succeeded. When every disable failed, the 90s wait buys nothing.
|
|
1370
|
+
# The wait happens BETWEEN cohorts so the failed-disable locations
|
|
1371
|
+
# don't pay the penalty.
|
|
1372
|
+
if items_disable_ok:
|
|
1373
|
+
console.print(" Waiting 90s for backend propagation...")
|
|
1374
|
+
time.sleep(90)
|
|
1375
|
+
|
|
1376
|
+
# Phase 3b: delete locations where disable SUCCEEDED — after wait.
|
|
1377
|
+
_delete_cohort(items_disable_ok)
|
|
1378
|
+
|
|
1379
|
+
console.print(f" Done: {deleted} deleted, {failed} failed")
|
|
1380
|
+
continue
|
|
1381
|
+
|
|
1382
|
+
results = execute_layer(api, layer_keys, inventory, org_id, max_concurrent)
|
|
1383
|
+
all_results.extend(results)
|
|
1384
|
+
|
|
1385
|
+
successes = sum(1 for r in results if r.success)
|
|
1386
|
+
failures = sum(1 for r in results if not r.success)
|
|
1387
|
+
console.print(f" Done: {successes} deleted, {failures} failed")
|
|
1388
|
+
|
|
1389
|
+
# Final summary
|
|
1390
|
+
console.print(format_results_summary(all_results))
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def format_results_summary(results: list[DeleteResult]) -> str:
|
|
1394
|
+
"""Format a summary of deletion results."""
|
|
1395
|
+
successes = [r for r in results if r.success]
|
|
1396
|
+
real_failures = [r for r in results if not r.success and not r.ccp_blocked]
|
|
1397
|
+
ccp_blocked = [r for r in results if r.ccp_blocked]
|
|
1398
|
+
ccp_blocked_locations = [
|
|
1399
|
+
r for r in ccp_blocked if r.resource_type == "Locations"
|
|
1400
|
+
]
|
|
1401
|
+
|
|
1402
|
+
lines = ["", "=== Cleanup Results ===", ""]
|
|
1403
|
+
lines.append(f" Deleted: {len(successes)}")
|
|
1404
|
+
lines.append(f" Failed: {len(real_failures)}")
|
|
1405
|
+
if ccp_blocked:
|
|
1406
|
+
lines.append(f" CCP-blocked: {len(ccp_blocked)}")
|
|
1407
|
+
|
|
1408
|
+
if real_failures:
|
|
1409
|
+
lines.append("")
|
|
1410
|
+
lines.append(" Failures:")
|
|
1411
|
+
for f in real_failures:
|
|
1412
|
+
lines.append(f" - {f.resource_type} {f.resource_name} ({f.resource_id}): {f.error}")
|
|
1413
|
+
|
|
1414
|
+
if ccp_blocked_locations:
|
|
1415
|
+
lines.append("")
|
|
1416
|
+
lines.append(
|
|
1417
|
+
f" NOTE: {len(ccp_blocked_locations)} location(s) blocked by "
|
|
1418
|
+
f"CCP backend — retry in a few hours:"
|
|
1419
|
+
)
|
|
1420
|
+
for r in ccp_blocked_locations:
|
|
1421
|
+
lines.append(f" - {r.resource_name} ({r.resource_id})")
|
|
1422
|
+
lines.append(
|
|
1423
|
+
" Re-run `wxcli cleanup run` later; the command is idempotent."
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
lines.append("")
|
|
1427
|
+
return "\n".join(lines)
|