netbox-super-cli 1.6.0__tar.gz → 1.6.2__tar.gz
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.
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/CHANGELOG.md +47 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/PKG-INFO +1 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/interactive-tui.md +5 -3
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/_version.py +1 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/builder/build.py +102 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cache/store.py +5 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/model/command_model.py +22 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/bulk.py +4 -2
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/fk.py +123 -13
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/forms.py +17 -4
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/bulk_edit_form.py +46 -7
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/detail.py +7 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/edit_form.py +18 -4
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/filter.py +9 -2
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/styles.tcss +4 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/pyproject.toml +1 -1
- netbox_super_cli-1.6.2/tests/builder/test_fk_metadata.py +148 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/test_store.py +12 -1
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_completion_protocol_smoke.py +2 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_dynamic_completion.py +2 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_edit_form.py +135 -2
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_edit_form.py +28 -1
- netbox_super_cli-1.6.2/tests/tui/test_fk_resolve.py +305 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_forms.py +15 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/uv.lock +1 -1
- netbox_super_cli-1.6.0/tests/tui/test_fk_resolve.py +0 -144
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/docs.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/dependabot.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/pull_request_template.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/bench.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/claude.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/docs.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/e2e.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/lint.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/release.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/skill-convention-review.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.github/workflows/test.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.gitignore +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.pre-commit-config.yaml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/.python-version +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/AGENTS.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/CLAUDE.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/LICENSE +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/README.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/caching.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/command-generation.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/http-client.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/overview.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/architecture/schema-loading.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/detail.svg +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/filter.svg +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/list.svg +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/picker.svg +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/assets/tui/search.svg +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/adding-bundled-schemas.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/branching.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/development.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/contributing/release-process.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/concepts.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/first-run.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/getting-started/install.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/audit-log.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/ci-and-automation.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/managing-profiles.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/output-formats.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/using-with-ai-agents.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/working-with-plugins.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/guides/writes-and-safety.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/index.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/cli.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/config.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/exit-codes.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/docs/reference/schemas.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/justfile +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/mkdocs.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/__main__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/aliases/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/aliases/resolver.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/auth/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/auth/verify.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/builder/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cache/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/aliases_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/app.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/cache_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/commands_dump.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/config_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/globals.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/handlers.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/init_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/login_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/profiles_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/registration.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/runtime.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/skill_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/tui_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/apply.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/bulk.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/coercion.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/confirmation.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/input.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/cli/writes/preflight.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/cache_probe.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/callbacks.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/completion/providers.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/loader.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/models.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/saved_searches.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/settings.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/config/writer.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/audit.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/client.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/http/retry.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/model/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/_console.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/colors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/csv_.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/explain.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/flatten.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/headers.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/json_.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/jsonl.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/render.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/table.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/output/yaml_.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/custom_fields.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/objecttypes.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/params.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/store.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/savedfilters/tags.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/hashing.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/loader.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/models.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schema/source.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/manifest.yaml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/skill/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/_bindings.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/app.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/catalog.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/columns.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/filters.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/keymap.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/nav.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/relations.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/columns.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/global_search.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/list.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/picker.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/record_picker.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/screens/saved_search_picker.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/search.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/selection.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/view.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/_modal.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_diff.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/bulk_summary.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/confirm.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/diff.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/help.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/nsc/tui/widgets/nav_tree.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/scripts/gen_docs.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/scripts/sync_agents_md.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/skills/netbox-super-cli/SKILL.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/aliases/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/aliases/test_resolver.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/audit/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/audit/test_redaction_full.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/auth/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/auth/test_verify.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/benchmarks/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/benchmarks/test_startup.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_build.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_default_columns.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_redaction_marking.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/builder/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cache/test_prune.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_aliases_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_app_smoke.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_cache_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_commands_dump.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_completion_smoke.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_config_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_explain.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_globals_color.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_handlers.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_init_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_lazy_textual_import.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_login_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_ndjson_input.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_profiles_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_registration.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_respx_integration.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_runtime.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_runtime_color.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_skill_commands.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_stdin_sniffer.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_tui_command.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/test_writes_respx.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_apply.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_bulk.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_confirmation.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handler_helpers.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_handlers_audit.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_input.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/cli/writes/test_preflight.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_loader.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_models.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_saved_search_fallback.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_saved_searches.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_settings_private_dir.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_atomicity.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_dotted_paths.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/config/test_writer_roundtrip.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/conftest.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/README.md +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/conftest.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/docker-compose.yml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_auth_error_envelope.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_full_cycle.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_login.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_ndjson.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_preflight_blocks_apply.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/e2e/wait_for_netbox.sh +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/profiles/single_profile.yaml +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/auth_401.json +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/circuits_providers_list.json +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_get.json +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit_permissions.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_client.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_client_redaction_threading.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/http/test_retry.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/test_command_model.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/model/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_colors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_console.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_csv.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors_aliases.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_errors_color.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_explain.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_explain_color.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_flatten.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_input_error_envelope.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_json.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_jsonl.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_render_dispatch.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_table.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_table_color.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/output/test_yaml.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_custom_fields.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_objecttypes.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_params.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_store.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/savedfilters/test_tags.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_hashing.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_loader.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_models.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_source.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/schema/test_source_ttl.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/test_gen_docs.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/scripts/test_sync_agents_md.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/skill/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/skill/test_bundle.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/test_packaging.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/__init__.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_app.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bindings.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_apply.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_diff_modal.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_import_isolation.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_bulk_summary.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_catalog.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_column_chooser.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_columns.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_detail_screen.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_diff.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_diff_modal.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_errors.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_filter_screen.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_filters.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_global_search.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_help_overlay.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_keymap.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_list_screen.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_picker.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_record_picker.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_relations.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_saved_filter_integration.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_search.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_selection.py +0 -0
- {netbox_super_cli-1.6.0 → netbox_super_cli-1.6.2}/tests/tui/test_view.py +0 -0
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`.
|
|
4
4
|
|
|
5
|
+
## v1.6.2 — 2026-06-30
|
|
6
|
+
|
|
7
|
+
Patch release. Fixes TUI foreign-key pickers resolving to the wrong resource.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Filtering/editing by `role` (and other FKs) opened the wrong picker**
|
|
12
|
+
([#139]). Choosing a `role` value when filtering devices listed IPAM roles
|
|
13
|
+
instead of device roles, because the picker matched the bare field name across
|
|
14
|
+
apps. Foreign-key pickers now resolve to the field's true target read from the
|
|
15
|
+
OpenAPI schema (a device/VM `role` → dcim device-roles, a prefix `role` → ipam
|
|
16
|
+
roles, a contact-assignment `role` → tenancy contact-roles), and fall back to a
|
|
17
|
+
context-qualified name match. This also corrects `group`, `type`, and similar
|
|
18
|
+
cross-app FK fields. Cached schemas rebuild once to pick up the new metadata.
|
|
19
|
+
|
|
20
|
+
[#139]: https://github.com/thomaschristory/netbox-super-cli/issues/139
|
|
21
|
+
|
|
22
|
+
## v1.6.1 — 2026-06-29
|
|
23
|
+
|
|
24
|
+
Patch release. Fixes the TUI bulk-edit form so custom-field edits are visible and
|
|
25
|
+
actually save.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Bulk-edit include toggles were invisible, so edits silently didn't save**
|
|
30
|
+
([#137]). The per-field include switch in the bulk-edit form was clipped to
|
|
31
|
+
zero width by its stylesheet and rendered blank — users couldn't see or flip
|
|
32
|
+
it, so no field was opted in and the apply produced no change with no error.
|
|
33
|
+
The toggle now sizes to fit its slider.
|
|
34
|
+
- **Edit forms showed the raw `custom_fields.<name>` key** ([#137]). The
|
|
35
|
+
single-edit and bulk-edit forms, and the change-preview diff, now show a custom
|
|
36
|
+
field's human label (e.g. "Site Contact") instead of the raw dotted key,
|
|
37
|
+
matching the list-column labels added in v1.6.0.
|
|
38
|
+
- **Opting in a custom-field select with an unresolved choice set could silently
|
|
39
|
+
null the field** ([#137]). A select whose choices couldn't be fetched rendered
|
|
40
|
+
empty; opting it in seeded a null and cleared a value the user never edited.
|
|
41
|
+
Opting in a blank select now contributes no change; only the explicit ∅ button
|
|
42
|
+
nulls.
|
|
43
|
+
- **Bulk-editing a shared custom field could overwrite it on opt-in** ([#137]).
|
|
44
|
+
Custom-field widgets didn't seed the value the selected records share, so a
|
|
45
|
+
shared boolean rendered as `False` and opting it in unchanged flipped every
|
|
46
|
+
record to `False` (and a shared multiselect cleared to empty). Custom-field
|
|
47
|
+
widgets now seed the shared value, so opting a field in without editing it is a
|
|
48
|
+
no-op.
|
|
49
|
+
|
|
50
|
+
[#137]: https://github.com/thomaschristory/netbox-super-cli/issues/137
|
|
51
|
+
|
|
5
52
|
## v1.6.0 — 2026-06-29
|
|
6
53
|
|
|
7
54
|
Minor release. The interactive TUI gets human-readable custom-field columns,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-super-cli
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: Dynamic NetBox CLI generated from the live OpenAPI schema.
|
|
5
5
|
Project-URL: Homepage, https://github.com/thomaschristory/netbox-super-cli
|
|
6
6
|
Project-URL: Documentation, https://thomaschristory.github.io/netbox-super-cli/
|
|
@@ -144,9 +144,11 @@ endpoint can filter by this one. Switch tabs with <kbd>Tab</kbd> and press
|
|
|
144
144
|
## Bulk editing
|
|
145
145
|
|
|
146
146
|
Select rows on a list with <kbd>v</kbd>/<kbd>Space</kbd>, then press
|
|
147
|
-
<kbd>B</kbd>.
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
<kbd>B</kbd>. Each field is one row: flip its **include toggle** (the switch on
|
|
148
|
+
the left) to opt it into the change, then set its value — a field with the toggle
|
|
149
|
+
off is left untouched. Custom fields appear as individual rows under their human
|
|
150
|
+
label. Fields are **prepopulated** with the value the selected records share, so
|
|
151
|
+
a small tweak doesn't mean retyping.
|
|
150
152
|
|
|
151
153
|
Press <kbd>p</kbd> to **preview** — a per-record diff of exactly what will
|
|
152
154
|
change (records already matching are left untouched). Confirm to apply; a
|
|
@@ -10,8 +10,10 @@ import re
|
|
|
10
10
|
from typing import Literal
|
|
11
11
|
|
|
12
12
|
from nsc.model.command_model import (
|
|
13
|
+
MODEL_FORMAT_VERSION,
|
|
13
14
|
CommandModel,
|
|
14
15
|
FieldShape,
|
|
16
|
+
FkResourceRef,
|
|
15
17
|
HttpMethod,
|
|
16
18
|
Operation,
|
|
17
19
|
Parameter,
|
|
@@ -77,6 +79,8 @@ def build_command_model(loaded: LoadedSchema) -> CommandModel:
|
|
|
77
79
|
info_version=doc.info.version,
|
|
78
80
|
schema_hash=loaded.hash,
|
|
79
81
|
tags=final_tags,
|
|
82
|
+
fk_resources=_build_fk_resources(doc),
|
|
83
|
+
format_version=MODEL_FORMAT_VERSION,
|
|
80
84
|
)
|
|
81
85
|
|
|
82
86
|
|
|
@@ -346,10 +350,107 @@ def _flat_fields(schema: SchemaObject, doc: OpenAPIDocument) -> dict[str, FieldS
|
|
|
346
350
|
resolved = _resolve_ref(prop, doc) or prop
|
|
347
351
|
primitive = _primitive(resolved)
|
|
348
352
|
enum = [str(v) for v in resolved.enum] if resolved.enum else None
|
|
349
|
-
out[name] = FieldShape(
|
|
353
|
+
out[name] = FieldShape(
|
|
354
|
+
primitive=primitive,
|
|
355
|
+
enum=enum,
|
|
356
|
+
nullable=resolved.nullable,
|
|
357
|
+
fk_target=_fk_target_component(prop),
|
|
358
|
+
)
|
|
350
359
|
return out
|
|
351
360
|
|
|
352
361
|
|
|
362
|
+
def _ref_component_name(ref: str | None) -> str | None:
|
|
363
|
+
prefix = "#/components/schemas/"
|
|
364
|
+
if isinstance(ref, str) and ref.startswith(prefix):
|
|
365
|
+
return ref[len(prefix) :]
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _normalize_serializer(name: str) -> str:
|
|
370
|
+
"""Reduce a serializer name to a key shared by a field's FK brief and the
|
|
371
|
+
target resource's list serializer: ``BriefDeviceRoleRequest`` → ``DeviceRole``
|
|
372
|
+
and ``DeviceWithConfigContext`` → ``Device`` both land on the same base."""
|
|
373
|
+
return name.removeprefix("Brief").removesuffix("Request").removesuffix("WithConfigContext")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _branch_ref(raw: dict[str, object]) -> str | None:
|
|
377
|
+
"""The `$ref` of a oneOf/anyOf branch, unwrapping a nullable `allOf` wrapper."""
|
|
378
|
+
ref = raw.get("$ref")
|
|
379
|
+
if isinstance(ref, str):
|
|
380
|
+
return ref
|
|
381
|
+
inner = raw.get("allOf")
|
|
382
|
+
if isinstance(inner, list):
|
|
383
|
+
for sub in inner:
|
|
384
|
+
if isinstance(sub, dict) and isinstance(sub.get("$ref"), str):
|
|
385
|
+
return str(sub["$ref"])
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _fk_target_component(prop: SchemaObject) -> str | None:
|
|
390
|
+
"""Target serializer name for a writable FK (`oneOf[integer, Brief<X>Request]`).
|
|
391
|
+
|
|
392
|
+
A `oneOf`/`anyOf` mixing an integer branch with a brief-ref branch is NetBox's
|
|
393
|
+
way of saying "an id or an object" — i.e. a foreign key. Returns `<X>`.
|
|
394
|
+
"""
|
|
395
|
+
extras = prop.model_extra or {}
|
|
396
|
+
branches = extras.get("oneOf") or extras.get("anyOf") or []
|
|
397
|
+
if not isinstance(branches, list):
|
|
398
|
+
return None
|
|
399
|
+
has_integer = False
|
|
400
|
+
ref_name: str | None = None
|
|
401
|
+
for raw in branches:
|
|
402
|
+
if not isinstance(raw, dict):
|
|
403
|
+
continue
|
|
404
|
+
if raw.get("type") == "integer":
|
|
405
|
+
has_integer = True
|
|
406
|
+
name = _ref_component_name(_branch_ref(raw))
|
|
407
|
+
if name is not None:
|
|
408
|
+
ref_name = name
|
|
409
|
+
if has_integer and ref_name is not None:
|
|
410
|
+
return _normalize_serializer(ref_name)
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _list_serializer_name(get_op: SchemaOperation, doc: OpenAPIDocument) -> str | None:
|
|
415
|
+
"""The item serializer name a collection GET returns (Paginated<X>List → <X>)."""
|
|
416
|
+
response = get_op.responses.get("200")
|
|
417
|
+
if response is None:
|
|
418
|
+
return None
|
|
419
|
+
media = response.content.get("application/json")
|
|
420
|
+
if media is None or media.schema_ is None:
|
|
421
|
+
return None
|
|
422
|
+
paginated_name = _ref_component_name(media.schema_.ref)
|
|
423
|
+
if paginated_name is None:
|
|
424
|
+
return None
|
|
425
|
+
paginated = doc.components.schemas.get(paginated_name)
|
|
426
|
+
if paginated is None or not paginated.properties:
|
|
427
|
+
return None
|
|
428
|
+
results = paginated.properties.get("results")
|
|
429
|
+
if results is None or results.items is None:
|
|
430
|
+
return None
|
|
431
|
+
return _ref_component_name(results.items.ref)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _build_fk_resources(doc: OpenAPIDocument) -> dict[str, FkResourceRef]:
|
|
435
|
+
"""Index each list endpoint's item serializer to its owning resource."""
|
|
436
|
+
index: dict[str, FkResourceRef] = {}
|
|
437
|
+
for path in sorted(doc.paths):
|
|
438
|
+
get_op = doc.paths[path].get
|
|
439
|
+
if get_op is None or not get_op.tags:
|
|
440
|
+
continue
|
|
441
|
+
tag_name = get_op.tags[0]
|
|
442
|
+
resource_name, is_collection = _resource_from_path(path, tag_name)
|
|
443
|
+
if resource_name is None or not is_collection:
|
|
444
|
+
continue
|
|
445
|
+
serializer = _list_serializer_name(get_op, doc)
|
|
446
|
+
if serializer is None:
|
|
447
|
+
continue
|
|
448
|
+
index.setdefault(
|
|
449
|
+
_normalize_serializer(serializer), FkResourceRef(tag=tag_name, resource=resource_name)
|
|
450
|
+
)
|
|
451
|
+
return index
|
|
452
|
+
|
|
453
|
+
|
|
353
454
|
def _primitive(schema: SchemaObject) -> PrimitiveType:
|
|
354
455
|
if schema.type is None:
|
|
355
456
|
return PrimitiveType.UNKNOWN
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
from nsc.config.models import Config, Profile
|
|
17
|
-
from nsc.model.command_model import CommandModel
|
|
17
|
+
from nsc.model.command_model import MODEL_FORMAT_VERSION, CommandModel
|
|
18
18
|
|
|
19
19
|
_LOG = logging.getLogger(__name__)
|
|
20
20
|
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
@@ -76,6 +76,10 @@ class CacheStore:
|
|
|
76
76
|
if model.schema_hash != schema_hash:
|
|
77
77
|
_LOG.warning("cache: hash mismatch for %s (file says %s)", target, model.schema_hash)
|
|
78
78
|
return None
|
|
79
|
+
if model.format_version != MODEL_FORMAT_VERSION:
|
|
80
|
+
# Built by an older nsc whose model lacks current metadata; rebuild.
|
|
81
|
+
_LOG.info("cache: stale model format for %s; rebuilding", target)
|
|
82
|
+
return None
|
|
79
83
|
return model
|
|
80
84
|
|
|
81
85
|
def save(self, profile: str, model: CommandModel) -> Path:
|
|
@@ -43,6 +43,12 @@ class PrimitiveType(StrEnum):
|
|
|
43
43
|
UNKNOWN = "unknown"
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
# Bumped whenever the builder changes the shape of a CommandModel in a way that
|
|
47
|
+
# makes older cached models incomplete (not just a schema-body change). The cache
|
|
48
|
+
# rebuilds entries stamped with an older version. See nsc/cache/store.py.
|
|
49
|
+
MODEL_FORMAT_VERSION = 1
|
|
50
|
+
|
|
51
|
+
|
|
46
52
|
class _Frozen(BaseModel):
|
|
47
53
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
48
54
|
|
|
@@ -60,6 +66,10 @@ class FieldShape(_Frozen):
|
|
|
60
66
|
primitive: PrimitiveType = PrimitiveType.UNKNOWN
|
|
61
67
|
enum: list[str] | None = None
|
|
62
68
|
nullable: bool = False
|
|
69
|
+
# For a writable foreign key (`oneOf[integer, Brief<X>Request]`), the target
|
|
70
|
+
# serializer name `<X>` (e.g. "DeviceRole"); None for non-FK fields. Pairs
|
|
71
|
+
# with CommandModel.fk_resources to find the target resource.
|
|
72
|
+
fk_target: str | None = None
|
|
63
73
|
|
|
64
74
|
|
|
65
75
|
class RequestBodyShape(_Frozen):
|
|
@@ -97,6 +107,13 @@ class Tag(_Frozen):
|
|
|
97
107
|
resources: dict[str, Resource] = Field(default_factory=dict)
|
|
98
108
|
|
|
99
109
|
|
|
110
|
+
class FkResourceRef(_Frozen):
|
|
111
|
+
"""Where a foreign-key serializer lives: which `tag` and `resource` serve it."""
|
|
112
|
+
|
|
113
|
+
tag: str
|
|
114
|
+
resource: str
|
|
115
|
+
|
|
116
|
+
|
|
100
117
|
class CommandModel(_Frozen):
|
|
101
118
|
"""The full normalized tree: tags → resources → operations."""
|
|
102
119
|
|
|
@@ -104,6 +121,11 @@ class CommandModel(_Frozen):
|
|
|
104
121
|
info_version: str
|
|
105
122
|
schema_hash: str
|
|
106
123
|
tags: dict[str, Tag] = Field(default_factory=dict)
|
|
124
|
+
# serializer name (e.g. "DeviceRole") → the resource whose list endpoint
|
|
125
|
+
# serves it. Lets the TUI resolve an FK picker to its true target.
|
|
126
|
+
fk_resources: dict[str, FkResourceRef] = Field(default_factory=dict)
|
|
127
|
+
# Defaults to 0 so models written before versioning read as stale and rebuild.
|
|
128
|
+
format_version: int = 0
|
|
107
129
|
|
|
108
130
|
def iter_operations(self) -> Iterator[tuple[str, str, Operation]]:
|
|
109
131
|
"""Yield `(tag, resource, operation)` triples in deterministic order."""
|
|
@@ -29,6 +29,7 @@ def bulk_diff(
|
|
|
29
29
|
bulk_set: dict[str, object],
|
|
30
30
|
sensitive_paths: tuple[str, ...],
|
|
31
31
|
new_displays: dict[str, str] | None = None,
|
|
32
|
+
field_labels: dict[str, str] | None = None,
|
|
32
33
|
) -> list[RecordChange]:
|
|
33
34
|
"""Compute the per-record patch and diff rows for a uniform bulk ``set``.
|
|
34
35
|
|
|
@@ -36,12 +37,13 @@ def bulk_diff(
|
|
|
36
37
|
record's current value contributes no patch entry and no row for that
|
|
37
38
|
record, so heterogeneous current values yield different changed subsets.
|
|
38
39
|
``new_displays`` maps a field to the human label of its chosen FK value so
|
|
39
|
-
the diff renders the name rather than the id.
|
|
40
|
+
the diff renders the name rather than the id. ``field_labels`` maps a field
|
|
41
|
+
key to its human label so ``custom_fields.<name>`` rows aren't shown raw.
|
|
40
42
|
"""
|
|
41
43
|
changes: list[RecordChange] = []
|
|
42
44
|
for record in selected:
|
|
43
45
|
patch = compute_patch(record, bulk_set)
|
|
44
|
-
rows = diff_rows(record, patch, sensitive_paths, new_displays)
|
|
46
|
+
rows = diff_rows(record, patch, sensitive_paths, new_displays, field_labels)
|
|
45
47
|
changes.append(RecordChange(record_id=record.get("id"), patch=patch, rows=rows))
|
|
46
48
|
return changes
|
|
47
49
|
|
|
@@ -20,7 +20,7 @@ from urllib.parse import urlsplit
|
|
|
20
20
|
|
|
21
21
|
from pydantic import BaseModel, ConfigDict
|
|
22
22
|
|
|
23
|
-
from nsc.model.command_model import CommandModel, Operation
|
|
23
|
+
from nsc.model.command_model import CommandModel, Operation, Resource
|
|
24
24
|
from nsc.tui.relations import singularize
|
|
25
25
|
|
|
26
26
|
FkKind = Literal["picker", "raw_id"]
|
|
@@ -51,12 +51,27 @@ class FkTarget(BaseModel):
|
|
|
51
51
|
hint: str | None = None
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def resolve_fk_target(
|
|
54
|
+
def resolve_fk_target(
|
|
55
|
+
field_name: str,
|
|
56
|
+
current_value: Any,
|
|
57
|
+
model: CommandModel,
|
|
58
|
+
*,
|
|
59
|
+
context_tag: str | None = None,
|
|
60
|
+
context_resource: str | None = None,
|
|
61
|
+
) -> FkTarget:
|
|
55
62
|
from_url = _resolve_from_url(field_name, current_value, model)
|
|
56
63
|
if from_url is not None:
|
|
57
64
|
return from_url
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
from_schema = _resolve_from_schema_fk(
|
|
67
|
+
field_name, model, context_tag=context_tag, context_resource=context_resource
|
|
68
|
+
)
|
|
69
|
+
if from_schema is not None:
|
|
70
|
+
return from_schema
|
|
71
|
+
|
|
72
|
+
by_name = _resolve_by_field_name(
|
|
73
|
+
field_name, model, context_tag=context_tag, context_resource=context_resource
|
|
74
|
+
)
|
|
60
75
|
if by_name is not None:
|
|
61
76
|
return by_name
|
|
62
77
|
|
|
@@ -67,6 +82,67 @@ def resolve_fk_target(field_name: str, current_value: Any, model: CommandModel)
|
|
|
67
82
|
)
|
|
68
83
|
|
|
69
84
|
|
|
85
|
+
def _resolve_from_schema_fk(
|
|
86
|
+
field_name: str,
|
|
87
|
+
model: CommandModel,
|
|
88
|
+
*,
|
|
89
|
+
context_tag: str | None,
|
|
90
|
+
context_resource: str | None,
|
|
91
|
+
) -> FkTarget | None:
|
|
92
|
+
"""Resolve via the schema-declared FK target of the owning resource's field.
|
|
93
|
+
|
|
94
|
+
The owning resource's create/update body types the field as a FK to a named
|
|
95
|
+
serializer (`fk_target`); `model.fk_resources` maps that serializer to the
|
|
96
|
+
resource that serves it. This resolves even when the field and resource names
|
|
97
|
+
diverge (a virtual-machine's `role` targets dcim `device-roles`).
|
|
98
|
+
"""
|
|
99
|
+
if context_resource is None:
|
|
100
|
+
return None
|
|
101
|
+
resource = _find_resource(model, context_resource, prefer_tag=context_tag)
|
|
102
|
+
if resource is None:
|
|
103
|
+
return None
|
|
104
|
+
base = field_name[:-3] if field_name.endswith("_id") else field_name
|
|
105
|
+
component = _field_fk_target(resource, base)
|
|
106
|
+
if component is None:
|
|
107
|
+
return None
|
|
108
|
+
ref = model.fk_resources.get(component)
|
|
109
|
+
if ref is None:
|
|
110
|
+
return None
|
|
111
|
+
tag = model.tags.get(ref.tag)
|
|
112
|
+
target = tag.resources.get(ref.resource) if tag is not None else None
|
|
113
|
+
if target is None or target.list_op is None:
|
|
114
|
+
return None
|
|
115
|
+
return FkTarget(
|
|
116
|
+
kind="picker",
|
|
117
|
+
field_name=field_name,
|
|
118
|
+
tag=ref.tag,
|
|
119
|
+
resource_name=ref.resource,
|
|
120
|
+
list_op=target.list_op,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _field_fk_target(resource: Resource, base: str) -> str | None:
|
|
125
|
+
for op in (resource.create_op, resource.update_op, resource.replace_op):
|
|
126
|
+
if op is None or op.request_body is None:
|
|
127
|
+
continue
|
|
128
|
+
shape = op.request_body.fields.get(base)
|
|
129
|
+
if shape is not None and shape.fk_target is not None:
|
|
130
|
+
return shape.fk_target
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _find_resource(model: CommandModel, name: str, *, prefer_tag: str | None) -> Resource | None:
|
|
135
|
+
if prefer_tag is not None and prefer_tag in model.tags:
|
|
136
|
+
found = model.tags[prefer_tag].resources.get(name)
|
|
137
|
+
if found is not None:
|
|
138
|
+
return found
|
|
139
|
+
for tag_name in sorted(model.tags):
|
|
140
|
+
found = model.tags[tag_name].resources.get(name)
|
|
141
|
+
if found is not None:
|
|
142
|
+
return found
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
70
146
|
def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel) -> FkTarget | None:
|
|
71
147
|
if not isinstance(current_value, dict):
|
|
72
148
|
return None
|
|
@@ -110,27 +186,61 @@ def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel)
|
|
|
110
186
|
)
|
|
111
187
|
|
|
112
188
|
|
|
113
|
-
def _resolve_by_field_name(
|
|
189
|
+
def _resolve_by_field_name(
|
|
190
|
+
field_name: str,
|
|
191
|
+
model: CommandModel,
|
|
192
|
+
*,
|
|
193
|
+
context_tag: str | None = None,
|
|
194
|
+
context_resource: str | None = None,
|
|
195
|
+
) -> FkTarget | None:
|
|
114
196
|
base = field_name[:-3] if field_name.endswith("_id") else field_name
|
|
115
197
|
wanted = singularize(base)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
198
|
+
|
|
199
|
+
# NetBox reuses bare FK names across apps: a device's `role` targets dcim
|
|
200
|
+
# `device-roles`, an ipam prefix's `role` targets ipam `roles`. The bare name
|
|
201
|
+
# can't disambiguate (`device-roles` does not singularize to `role`), so when
|
|
202
|
+
# the owning resource is known we first try the qualified target name
|
|
203
|
+
# `<owner>-<field>` (e.g. `device-role`), preferring the owner's own tag,
|
|
204
|
+
# before falling back to the bare name's global scan.
|
|
205
|
+
candidates: list[str] = []
|
|
206
|
+
if context_resource is not None:
|
|
207
|
+
candidates.append(f"{singularize(context_resource)}-{wanted}")
|
|
208
|
+
candidates.append(wanted)
|
|
209
|
+
|
|
210
|
+
for candidate in candidates:
|
|
211
|
+
located = _match_resource_by_singular(model, candidate, prefer_tag=context_tag)
|
|
212
|
+
if located is not None:
|
|
213
|
+
tag_name, resource_name, list_op = located
|
|
124
214
|
return FkTarget(
|
|
125
215
|
kind="picker",
|
|
126
216
|
field_name=field_name,
|
|
127
217
|
tag=tag_name,
|
|
128
218
|
resource_name=resource_name,
|
|
129
|
-
list_op=
|
|
219
|
+
list_op=list_op,
|
|
130
220
|
)
|
|
131
221
|
return None
|
|
132
222
|
|
|
133
223
|
|
|
224
|
+
def _match_resource_by_singular(
|
|
225
|
+
model: CommandModel, wanted: str, *, prefer_tag: str | None
|
|
226
|
+
) -> tuple[str, str, Operation] | None:
|
|
227
|
+
for tag_name in _tag_search_order(model, prefer_tag):
|
|
228
|
+
tag = model.tags[tag_name]
|
|
229
|
+
for resource_name in sorted(tag.resources):
|
|
230
|
+
resource = tag.resources[resource_name]
|
|
231
|
+
if singularize(resource_name) != wanted or resource.list_op is None:
|
|
232
|
+
continue
|
|
233
|
+
return (tag_name, resource_name, resource.list_op)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _tag_search_order(model: CommandModel, prefer_tag: str | None) -> list[str]:
|
|
238
|
+
rest = sorted(model.tags)
|
|
239
|
+
if prefer_tag is not None and prefer_tag in model.tags:
|
|
240
|
+
return [prefer_tag, *(t for t in rest if t != prefer_tag)]
|
|
241
|
+
return rest
|
|
242
|
+
|
|
243
|
+
|
|
134
244
|
def _locate_resource(
|
|
135
245
|
resource_name: str, model: CommandModel
|
|
136
246
|
) -> tuple[str, Operation | None] | None:
|
|
@@ -35,6 +35,9 @@ class WidgetSpec(BaseModel):
|
|
|
35
35
|
|
|
36
36
|
kind: WidgetKind
|
|
37
37
|
name: str
|
|
38
|
+
# Human-friendly row label; falls back to ``name`` when blank (regular fields
|
|
39
|
+
# keep their key as the label; custom fields carry their NetBox label here).
|
|
40
|
+
label: str = ""
|
|
38
41
|
choices: tuple[str, ...] = ()
|
|
39
42
|
nullable: bool = False
|
|
40
43
|
sensitive: bool = False
|
|
@@ -83,15 +86,20 @@ def custom_field_widget(cf: CustomFieldDef) -> WidgetSpec:
|
|
|
83
86
|
kind = _CF_KINDS.get(cf.type, "text")
|
|
84
87
|
nullable = not cf.required
|
|
85
88
|
if kind == "select":
|
|
86
|
-
return WidgetSpec(
|
|
89
|
+
return WidgetSpec(
|
|
90
|
+
kind="select", name=name, label=cf.label, choices=cf.choices, nullable=nullable
|
|
91
|
+
)
|
|
87
92
|
if kind == "multi_select":
|
|
88
93
|
return WidgetSpec(
|
|
89
94
|
kind="multi_select",
|
|
90
95
|
name=name,
|
|
96
|
+
label=cf.label,
|
|
91
97
|
options=tuple((c, c) for c in cf.choices),
|
|
92
98
|
nullable=nullable,
|
|
93
99
|
)
|
|
94
|
-
return WidgetSpec(
|
|
100
|
+
return WidgetSpec(
|
|
101
|
+
kind=kind, name=name, label=cf.label, nullable=nullable, is_float=cf.type == "decimal"
|
|
102
|
+
)
|
|
95
103
|
|
|
96
104
|
|
|
97
105
|
def expand_custom_fields(defs: dict[str, CustomFieldDef]) -> list[WidgetSpec]:
|
|
@@ -242,20 +250,25 @@ def diff_rows(
|
|
|
242
250
|
patch: dict[str, object],
|
|
243
251
|
sensitive_paths: tuple[str, ...],
|
|
244
252
|
new_displays: dict[str, str] | None = None,
|
|
253
|
+
field_labels: dict[str, str] | None = None,
|
|
245
254
|
) -> list[DiffRow]:
|
|
246
255
|
"""Render ``patch`` as human-readable old -> new rows for the confirm modal.
|
|
247
256
|
|
|
248
257
|
``new_displays`` overrides a field's rendered *new* value — used for FK
|
|
249
258
|
fields whose staged value is a bare id but whose chosen label is known
|
|
250
259
|
(e.g. picked from the record chooser). The patch still carries the id.
|
|
260
|
+
``field_labels`` overrides a field's *name* — used so a ``custom_fields.<name>``
|
|
261
|
+
key renders as its human label rather than the raw dotted key.
|
|
251
262
|
"""
|
|
252
263
|
overrides = new_displays or {}
|
|
264
|
+
labels = field_labels or {}
|
|
253
265
|
rows: list[DiffRow] = []
|
|
254
266
|
for name, new_value in patch.items():
|
|
267
|
+
field = labels.get(name, name)
|
|
255
268
|
if name in sensitive_paths:
|
|
256
|
-
rows.append(DiffRow(field=
|
|
269
|
+
rows.append(DiffRow(field=field, old_display="****", new_display="****"))
|
|
257
270
|
continue
|
|
258
271
|
old_display = fk_display(original[name]) if name in original else ""
|
|
259
272
|
new_display = overrides.get(name, str(new_value))
|
|
260
|
-
rows.append(DiffRow(field=
|
|
273
|
+
rows.append(DiffRow(field=field, old_display=old_display, new_display=new_display))
|
|
261
274
|
return rows
|
|
@@ -100,9 +100,15 @@ class BulkEditForm(Screen[None]):
|
|
|
100
100
|
self._fk_labels: dict[str, str] = {}
|
|
101
101
|
body = update_op.request_body
|
|
102
102
|
field_names = list(body.fields) if body is not None else []
|
|
103
|
+
# Custom fields are staged under flattened ``custom_fields.<name>`` keys,
|
|
104
|
+
# so seed their shared value from flattened records too — otherwise a
|
|
105
|
+
# widget defaults (e.g. a boolean to False) and opting it in unchanged
|
|
106
|
+
# would silently overwrite the records' shared value.
|
|
107
|
+
cf_names = [f"custom_fields.{cf.name}" for cf in (custom_field_defs or {}).values()]
|
|
108
|
+
flattened_records = [flatten_custom_fields(record) for record in selected_records]
|
|
103
109
|
# Shared current value per field, to seed the widgets (does NOT opt the
|
|
104
110
|
# field in — the include toggle still gates what gets set).
|
|
105
|
-
self._shared = shared_values(
|
|
111
|
+
self._shared = shared_values(flattened_records, field_names + cf_names)
|
|
106
112
|
self.progress_total = 0
|
|
107
113
|
self.progress_done = 0
|
|
108
114
|
self.title = f"Bulk edit {len(selected_records)} {resource_name}"
|
|
@@ -111,6 +117,10 @@ class BulkEditForm(Screen[None]):
|
|
|
111
117
|
def bulk_set(self) -> dict[str, Any]:
|
|
112
118
|
return {name: self._values[name] for name in self._included if name in self._values}
|
|
113
119
|
|
|
120
|
+
def _field_labels(self) -> dict[str, str]:
|
|
121
|
+
"""Human labels for fields that carry one (custom fields), for the diff."""
|
|
122
|
+
return {name: spec.label for name, spec in self._specs.items() if spec.label}
|
|
123
|
+
|
|
114
124
|
def compose(self) -> ComposeResult:
|
|
115
125
|
yield Header()
|
|
116
126
|
with VerticalScroll(id="bulk-form-body"):
|
|
@@ -142,7 +152,7 @@ class BulkEditForm(Screen[None]):
|
|
|
142
152
|
def _compose_field(self, name: str, spec: WidgetSpec) -> ComposeResult:
|
|
143
153
|
with Horizontal(classes="bulk-field"):
|
|
144
154
|
yield Switch(value=False, id=f"include-{encode_field_id(name)}", classes="bulk-include")
|
|
145
|
-
yield Label(name, classes="bulk-label")
|
|
155
|
+
yield Label(spec.label or name, classes="bulk-label")
|
|
146
156
|
yield from self._compose_widget(name, spec)
|
|
147
157
|
if spec.nullable and spec.kind != "multi_select":
|
|
148
158
|
yield Button("∅", id=f"setnull-{encode_field_id(name)}", classes="bulk-setnull")
|
|
@@ -173,7 +183,13 @@ class BulkEditForm(Screen[None]):
|
|
|
173
183
|
return str(shared_id)
|
|
174
184
|
|
|
175
185
|
def _compose_fk(self, name: str) -> ComposeResult:
|
|
176
|
-
target = resolve_fk_target(
|
|
186
|
+
target = resolve_fk_target(
|
|
187
|
+
name,
|
|
188
|
+
self._fk_nested_value(name),
|
|
189
|
+
self._model,
|
|
190
|
+
context_tag=self._tag,
|
|
191
|
+
context_resource=self._resource_name,
|
|
192
|
+
)
|
|
177
193
|
self._fk_kinds[name] = target.kind
|
|
178
194
|
shared_id = self._shared.get(name)
|
|
179
195
|
if target.kind == "raw_id":
|
|
@@ -191,8 +207,16 @@ class BulkEditForm(Screen[None]):
|
|
|
191
207
|
def _compose_widget(self, name: str, spec: WidgetSpec) -> ComposeResult:
|
|
192
208
|
wid = f"field-{encode_field_id(name)}"
|
|
193
209
|
if spec.kind == "multi_select":
|
|
210
|
+
# Tags seed via spec.selected; a custom-field multiselect seeds its
|
|
211
|
+
# shared current list so opting it in unchanged isn't a destructive
|
|
212
|
+
# clear (tags are intentionally left blank — they have their own flow).
|
|
213
|
+
selected = set(spec.selected)
|
|
214
|
+
if not selected and name != "tags":
|
|
215
|
+
shared = self._shared.get(name)
|
|
216
|
+
if isinstance(shared, list):
|
|
217
|
+
selected = {str(v) for v in shared}
|
|
194
218
|
yield SelectionList[str](
|
|
195
|
-
*(Selection(label, val, val in
|
|
219
|
+
*(Selection(label, val, val in selected) for label, val in spec.options),
|
|
196
220
|
id=wid,
|
|
197
221
|
classes="bulk-multiselect",
|
|
198
222
|
)
|
|
@@ -264,7 +288,10 @@ class BulkEditForm(Screen[None]):
|
|
|
264
288
|
return self._multiselect_value(name, self.query_one(wid, SelectionList).selected)
|
|
265
289
|
if spec is not None and spec.kind == "select":
|
|
266
290
|
value = self.query_one(wid, Select).value
|
|
267
|
-
|
|
291
|
+
# A blank select (no/unresolved choices, or a current value not in the
|
|
292
|
+
# options) must not seed a null on opt-in — that would silently clear a
|
|
293
|
+
# field the user never edited. Only the explicit ∅ button nulls.
|
|
294
|
+
return _NO_SEED if value is Select.NULL else value
|
|
268
295
|
if spec is not None and spec.kind == "switch":
|
|
269
296
|
return self.query_one(wid, Switch).value
|
|
270
297
|
return self._coerce_input(name, self.query_one(wid, Input).value)
|
|
@@ -279,6 +306,10 @@ class BulkEditForm(Screen[None]):
|
|
|
279
306
|
name = self._strip(event.select.id, "field-")
|
|
280
307
|
if name is None:
|
|
281
308
|
return
|
|
309
|
+
# A blank select on a field the user hasn't opted in must not stage a null
|
|
310
|
+
# (which would clear the field on apply). Only the explicit ∅ button nulls.
|
|
311
|
+
if event.value is Select.NULL and name not in self._included:
|
|
312
|
+
return
|
|
282
313
|
self._values[name] = None if event.value is Select.NULL else event.value
|
|
283
314
|
|
|
284
315
|
def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged[str]) -> None:
|
|
@@ -306,7 +337,13 @@ class BulkEditForm(Screen[None]):
|
|
|
306
337
|
self._open_picker(fk)
|
|
307
338
|
|
|
308
339
|
def _open_picker(self, name: str) -> None:
|
|
309
|
-
target = resolve_fk_target(
|
|
340
|
+
target = resolve_fk_target(
|
|
341
|
+
name,
|
|
342
|
+
self._fk_nested_value(name),
|
|
343
|
+
self._model,
|
|
344
|
+
context_tag=self._tag,
|
|
345
|
+
context_resource=self._resource_name,
|
|
346
|
+
)
|
|
310
347
|
if target.list_op is None:
|
|
311
348
|
return
|
|
312
349
|
from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
|
|
@@ -329,7 +366,9 @@ class BulkEditForm(Screen[None]):
|
|
|
329
366
|
sensitive = body.sensitive_paths if body is not None else ()
|
|
330
367
|
# Flatten custom_fields so each chosen custom_fields.<name> diffs per record.
|
|
331
368
|
flattened = [flatten_custom_fields(record) for record in self._selected]
|
|
332
|
-
changes = bulk_diff(
|
|
369
|
+
changes = bulk_diff(
|
|
370
|
+
flattened, self.bulk_set, sensitive, self._fk_labels, self._field_labels()
|
|
371
|
+
)
|
|
333
372
|
|
|
334
373
|
def _on_confirm(confirmed: bool | None) -> None:
|
|
335
374
|
if confirmed:
|