netbox-super-cli 1.2.0__tar.gz → 1.2.1__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.2.0 → netbox_super_cli-1.2.1}/CHANGELOG.md +21 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/PKG-INFO +1 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/_version.py +1 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/bulk.py +4 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/fk.py +21 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/forms.py +22 -4
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/bulk_edit_form.py +82 -5
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/detail.py +14 -6
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/edit_form.py +29 -7
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/pyproject.toml +1 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_diff.py +9 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_edit_form.py +232 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_detail_screen.py +91 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_diff.py +3 -2
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_edit_form.py +53 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_fk_resolve.py +14 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_forms.py +43 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/uv.lock +1 -1
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/bench.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/claude.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/docs.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/e2e.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/lint.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/release.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.github/workflows/test.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.gitignore +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.pre-commit-config.yaml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/.python-version +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/AGENTS.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/CLAUDE.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/LICENSE +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/README.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/caching.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/command-generation.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/http-client.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/overview.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/architecture/schema-loading.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/detail.svg +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/filter.svg +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/list.svg +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/picker.svg +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/assets/tui/search.svg +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/adding-bundled-schemas.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/branching.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/development.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/contributing/release-process.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/concepts.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/first-run.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/getting-started/install.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/ci-and-automation.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/interactive-tui.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/managing-profiles.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/output-formats.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/using-with-ai-agents.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/working-with-plugins.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/guides/writes-and-safety.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/index.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/cli.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/config.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/exit-codes.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/docs/reference/schemas.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/justfile +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/mkdocs.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/__main__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/aliases/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/aliases/resolver.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/auth/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/auth/verify.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/builder/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/builder/build.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cache/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cache/store.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/aliases_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/app.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/cache_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/commands_dump.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/config_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/globals.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/handlers.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/init_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/login_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/profiles_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/registration.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/runtime.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/skill_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/tui_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/apply.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/bulk.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/coercion.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/confirmation.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/input.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/cli/writes/preflight.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/loader.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/models.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/settings.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/config/writer.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/audit.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/client.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/http/retry.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/model/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/model/command_model.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/_console.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/csv_.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/explain.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/flatten.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/headers.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/json_.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/jsonl.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/render.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/table.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/output/yaml_.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/hashing.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/loader.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/models.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schema/source.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/manifest.yaml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/netbox-4.5.10.json.gz +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/schemas/bundled/netbox-4.6.0.json.gz +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/skill/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/_bindings.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/app.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/catalog.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/columns.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/filters.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/keymap.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/nav.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/relations.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/columns.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/filter.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/global_search.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/list.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/picker.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/screens/record_picker.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/search.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/selection.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/styles.tcss +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/view.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/_modal.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/bulk_diff.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/bulk_summary.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/confirm.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/diff.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/help.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/nsc/tui/widgets/nav_tree.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/scripts/gen_docs.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/scripts/sync_agents_md.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/skills/netbox-super-cli/SKILL.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/aliases/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/aliases/test_resolver.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/auth/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/auth/test_verify.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/benchmarks/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/benchmarks/test_startup.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_build.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_default_columns.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_redaction_marking.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/builder/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/test_prune.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cache/test_store.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_aliases_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_app_smoke.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_cache_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_commands_dump.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_completion_smoke.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_config_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_explain.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_globals_color.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_handlers.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_init_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_lazy_textual_import.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_login_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_meta_subcommands_under_broken_config.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_ndjson_input.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_profiles_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_registration.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_respx_integration.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_runtime.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_runtime_color.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_skill_commands.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_stdin_sniffer.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_tui_command.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/test_writes_respx.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_apply.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_bulk.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_confirmation.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_handler_helpers.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_handlers_audit.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_input.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/cli/writes/test_preflight.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_loader.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_models.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_atomicity.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_dotted_paths.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/config/test_writer_roundtrip.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/conftest.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/README.md +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/conftest.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/docker-compose.yml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_auth_error_envelope.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_bulk_create_with_loop_fallback.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_full_cycle.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_login.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_ndjson.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_preflight_blocks_apply.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/test_validation_error_envelope_from_netbox.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/e2e/wait_for_netbox.sh +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/profiles/single_profile.yaml +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/auth_401.json +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/circuits_providers_list.json +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_get.json +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_list_p1.json +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/fixtures/responses/dcim_devices_list_p2.json +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit_permissions.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_audit_redaction.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_client.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_client_redaction_threading.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/http/test_retry.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/test_command_model.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/model/test_request_body_shape.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_console.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_csv.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors_aliases.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_errors_color.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_explain.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_explain_color.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_flatten.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_input_error_envelope.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_json.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_jsonl.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_render_dispatch.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_table.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_table_color.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/output/test_yaml.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_hashing.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_loader.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_models.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_source.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/schema/test_source_ttl.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/test_gen_docs.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/scripts/test_sync_agents_md.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/skill/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/skill/test_bundle.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/test_packaging.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/__init__.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_app.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bindings.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_apply.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_diff_modal.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_import_isolation.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_bulk_summary.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_catalog.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_column_chooser.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_columns.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_diff_modal.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_errors.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_filter_screen.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_filters.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_global_search.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_help_overlay.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_keymap.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_list_screen.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_picker.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_record_picker.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_relations.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_search.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_selection.py +0 -0
- {netbox_super_cli-1.2.0 → netbox_super_cli-1.2.1}/tests/tui/test_view.py +0 -0
|
@@ -2,6 +2,27 @@
|
|
|
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.2.1 — 2026-06-25
|
|
6
|
+
|
|
7
|
+
Patch release. Fixes foreign-key handling in the interactive TUI.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **TUI foreign-key fields show names and a chooser, not raw IDs** ([#97]).
|
|
12
|
+
In the bulk-edit, single-edit and detail screens, FK fields (device `role`,
|
|
13
|
+
`site`, `tenant`, `device_type`, …) now render their human-readable
|
|
14
|
+
`display`/`name` in the confirmation diff and present a searchable
|
|
15
|
+
record-picker dropdown of valid related objects, instead of showing the
|
|
16
|
+
numeric id and requiring a raw id to be typed. The outgoing `PATCH` still
|
|
17
|
+
carries the id, so the wire format is unchanged. FK detection is now keyed on
|
|
18
|
+
the record's nested object rather than the widget kind, because NetBox's
|
|
19
|
+
writable schema types FK fields as `oneOf[integer, brief]` (no primitive
|
|
20
|
+
type), which previously fell through to a free-text widget. When a relation's
|
|
21
|
+
target resource has no list endpoint, the field gracefully falls back to
|
|
22
|
+
raw-id entry with a hint rather than an inert chooser button.
|
|
23
|
+
|
|
24
|
+
[#97]: https://github.com/thomaschristory/netbox-super-cli/issues/97
|
|
25
|
+
|
|
5
26
|
## v1.2.0 — 2026-06-24
|
|
6
27
|
|
|
7
28
|
Minor release. Adds an **interactive terminal UI** — a keyboard-driven, fully
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: netbox-super-cli
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
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: Issues, https://github.com/thomaschristory/netbox-super-cli/issues
|
|
@@ -28,17 +28,20 @@ def bulk_diff(
|
|
|
28
28
|
selected: list[dict[str, object]],
|
|
29
29
|
bulk_set: dict[str, object],
|
|
30
30
|
sensitive_paths: tuple[str, ...],
|
|
31
|
+
new_displays: dict[str, str] | None = None,
|
|
31
32
|
) -> list[RecordChange]:
|
|
32
33
|
"""Compute the per-record patch and diff rows for a uniform bulk ``set``.
|
|
33
34
|
|
|
34
35
|
Order of ``selected`` is preserved. A field whose new value equals a
|
|
35
36
|
record's current value contributes no patch entry and no row for that
|
|
36
37
|
record, so heterogeneous current values yield different changed subsets.
|
|
38
|
+
``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.
|
|
37
40
|
"""
|
|
38
41
|
changes: list[RecordChange] = []
|
|
39
42
|
for record in selected:
|
|
40
43
|
patch = compute_patch(record, bulk_set)
|
|
41
|
-
rows = diff_rows(record, patch, sensitive_paths)
|
|
44
|
+
rows = diff_rows(record, patch, sensitive_paths, new_displays)
|
|
42
45
|
changes.append(RecordChange(record_id=record.get("id"), patch=patch, rows=rows))
|
|
43
46
|
return changes
|
|
44
47
|
|
|
@@ -28,6 +28,17 @@ FkKind = Literal["picker", "raw_id"]
|
|
|
28
28
|
_MIN_URL_SEGMENTS = 2
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def is_fk_value(value: Any) -> bool:
|
|
32
|
+
"""True when ``value`` is a NetBox FK nested object (carries ``id``/``url``).
|
|
33
|
+
|
|
34
|
+
The writable schema types FK fields as ``oneOf[integer, brief-ref]`` with no
|
|
35
|
+
top-level ``type``, so the model has no FK signal; the reliable runtime cue
|
|
36
|
+
is the record's nested object. A bare ``custom_fields`` dict (no id/url) is
|
|
37
|
+
deliberately excluded.
|
|
38
|
+
"""
|
|
39
|
+
return isinstance(value, dict) and ("id" in value or "url" in value)
|
|
40
|
+
|
|
41
|
+
|
|
31
42
|
class FkTarget(BaseModel):
|
|
32
43
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
33
44
|
|
|
@@ -79,6 +90,16 @@ def _resolve_from_url(field_name: str, current_value: Any, model: CommandModel)
|
|
|
79
90
|
)
|
|
80
91
|
|
|
81
92
|
tag, list_op = located
|
|
93
|
+
# A `picker` target must carry a usable list endpoint — otherwise the screen
|
|
94
|
+
# renders a chooser button that can't open anything. Resolvable but
|
|
95
|
+
# list-less resources fall back to raw-ID entry, like an unknown resource.
|
|
96
|
+
if list_op is None:
|
|
97
|
+
return FkTarget(
|
|
98
|
+
kind="raw_id",
|
|
99
|
+
field_name=field_name,
|
|
100
|
+
current_id=current_id,
|
|
101
|
+
hint=(f"'{resource_name}' has no list endpoint; enter the numeric ID."),
|
|
102
|
+
)
|
|
82
103
|
return FkTarget(
|
|
83
104
|
kind="picker",
|
|
84
105
|
field_name=field_name,
|
|
@@ -87,8 +87,18 @@ def compute_patch(original: dict[str, object], staged: dict[str, object]) -> dic
|
|
|
87
87
|
return patch
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def
|
|
90
|
+
def fk_display(value: object) -> str:
|
|
91
|
+
"""Human-readable label for a value, resolving FK nested objects.
|
|
92
|
+
|
|
93
|
+
NetBox FK objects carry ``display`` (and usually ``name``/``slug``); prefer
|
|
94
|
+
those over the bare ``id`` so the diff shows e.g. ``Top of Rack Switch``
|
|
95
|
+
rather than ``12``. Non-dict values render via ``str``.
|
|
96
|
+
"""
|
|
91
97
|
if isinstance(value, dict):
|
|
98
|
+
for key in ("display", "name", "slug"):
|
|
99
|
+
label = value.get(key)
|
|
100
|
+
if label:
|
|
101
|
+
return str(label)
|
|
92
102
|
ident = value.get("id")
|
|
93
103
|
if ident is not None:
|
|
94
104
|
return str(ident)
|
|
@@ -99,13 +109,21 @@ def diff_rows(
|
|
|
99
109
|
original: dict[str, object],
|
|
100
110
|
patch: dict[str, object],
|
|
101
111
|
sensitive_paths: tuple[str, ...],
|
|
112
|
+
new_displays: dict[str, str] | None = None,
|
|
102
113
|
) -> list[DiffRow]:
|
|
103
|
-
"""Render ``patch`` as human-readable old -> new rows for the confirm modal.
|
|
114
|
+
"""Render ``patch`` as human-readable old -> new rows for the confirm modal.
|
|
115
|
+
|
|
116
|
+
``new_displays`` overrides a field's rendered *new* value — used for FK
|
|
117
|
+
fields whose staged value is a bare id but whose chosen label is known
|
|
118
|
+
(e.g. picked from the record chooser). The patch still carries the id.
|
|
119
|
+
"""
|
|
120
|
+
overrides = new_displays or {}
|
|
104
121
|
rows: list[DiffRow] = []
|
|
105
122
|
for name, new_value in patch.items():
|
|
106
123
|
if name in sensitive_paths:
|
|
107
124
|
rows.append(DiffRow(field=name, old_display="****", new_display="****"))
|
|
108
125
|
continue
|
|
109
|
-
old_display =
|
|
110
|
-
|
|
126
|
+
old_display = fk_display(original[name]) if name in original else ""
|
|
127
|
+
new_display = overrides.get(name, str(new_value))
|
|
128
|
+
rows.append(DiffRow(field=name, old_display=old_display, new_display=new_display))
|
|
111
129
|
return rows
|
|
@@ -21,9 +21,14 @@ from textual.widgets import Button, Footer, Header, Input, Label, ProgressBar, S
|
|
|
21
21
|
from nsc.model.command_model import CommandModel, Operation
|
|
22
22
|
from nsc.tui._bindings import textual_bindings
|
|
23
23
|
from nsc.tui.bulk import RecordChange, shared_values
|
|
24
|
-
from nsc.tui.
|
|
24
|
+
from nsc.tui.fk import is_fk_value, resolve_fk_target
|
|
25
|
+
from nsc.tui.forms import SET_NULL, WidgetSpec, field_to_widget, fk_display
|
|
25
26
|
from nsc.tui.view import detail_path
|
|
26
27
|
|
|
28
|
+
# Sentinel: a picker FK opted in with neither a pick nor a shared value
|
|
29
|
+
# contributes nothing, so it never nulls the relation.
|
|
30
|
+
_NO_SEED = object()
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
class _Client(Protocol):
|
|
29
34
|
def paginate(
|
|
@@ -62,6 +67,8 @@ class BulkEditForm(Screen[None]):
|
|
|
62
67
|
self._specs: dict[str, WidgetSpec] = {}
|
|
63
68
|
self._values: dict[str, Any] = {}
|
|
64
69
|
self._included: set[str] = set()
|
|
70
|
+
self._fk_kinds: dict[str, str] = {}
|
|
71
|
+
self._fk_labels: dict[str, str] = {}
|
|
65
72
|
body = update_op.request_body
|
|
66
73
|
field_names = list(body.fields) if body is not None else []
|
|
67
74
|
# Shared current value per field, to seed the widgets (does NOT opt the
|
|
@@ -99,7 +106,47 @@ class BulkEditForm(Screen[None]):
|
|
|
99
106
|
if spec.nullable:
|
|
100
107
|
yield Button("∅", id=f"setnull-{name}", classes="bulk-setnull")
|
|
101
108
|
|
|
109
|
+
def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
|
|
110
|
+
if spec.kind in ("select", "switch", "masked") or spec.is_float:
|
|
111
|
+
return False
|
|
112
|
+
return name.endswith("_id") or any(
|
|
113
|
+
is_fk_value(record.get(name)) for record in self._selected
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _fk_nested_value(self, name: str) -> Any:
|
|
117
|
+
"""A representative nested FK object across the selection, for resolution."""
|
|
118
|
+
for record in self._selected:
|
|
119
|
+
value = record.get(name)
|
|
120
|
+
if is_fk_value(value):
|
|
121
|
+
return value
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _fk_seed_label(self, name: str) -> str:
|
|
125
|
+
shared_id = self._shared.get(name)
|
|
126
|
+
if shared_id is None:
|
|
127
|
+
return ""
|
|
128
|
+
for record in self._selected:
|
|
129
|
+
value = record.get(name)
|
|
130
|
+
if isinstance(value, dict) and value.get("id") == shared_id:
|
|
131
|
+
return fk_display(value)
|
|
132
|
+
return str(shared_id)
|
|
133
|
+
|
|
134
|
+
def _compose_fk(self, name: str) -> ComposeResult:
|
|
135
|
+
target = resolve_fk_target(name, self._fk_nested_value(name), self._model)
|
|
136
|
+
self._fk_kinds[name] = target.kind
|
|
137
|
+
shared_id = self._shared.get(name)
|
|
138
|
+
if target.kind == "raw_id":
|
|
139
|
+
text = "" if shared_id is None else str(shared_id)
|
|
140
|
+
yield Input(value=text, id=f"field-{name}")
|
|
141
|
+
if target.hint:
|
|
142
|
+
yield Label(target.hint, classes="bulk-fk-hint")
|
|
143
|
+
return
|
|
144
|
+
yield Button(f"{name}: {self._fk_seed_label(name)}", id=f"fk-{name}", classes="bulk-fk")
|
|
145
|
+
|
|
102
146
|
def _compose_widget(self, name: str, spec: WidgetSpec) -> ComposeResult:
|
|
147
|
+
if self._is_fk(name, spec):
|
|
148
|
+
yield from self._compose_fk(name)
|
|
149
|
+
return
|
|
103
150
|
shared = self._shared.get(name)
|
|
104
151
|
if spec.kind == "select":
|
|
105
152
|
options = [(choice, choice) for choice in spec.choices]
|
|
@@ -126,12 +173,15 @@ class BulkEditForm(Screen[None]):
|
|
|
126
173
|
|
|
127
174
|
def _coerce_input(self, name: str, raw: str) -> Any:
|
|
128
175
|
spec = self._specs.get(name)
|
|
129
|
-
|
|
176
|
+
numeric = (spec is not None and spec.kind == "number") or self._fk_kinds.get(
|
|
177
|
+
name
|
|
178
|
+
) == "raw_id"
|
|
179
|
+
if not numeric:
|
|
130
180
|
return raw
|
|
131
181
|
if raw == "":
|
|
132
182
|
return None
|
|
133
183
|
try:
|
|
134
|
-
return float(raw) if spec.is_float else int(raw)
|
|
184
|
+
return float(raw) if spec is not None and spec.is_float else int(raw)
|
|
135
185
|
except ValueError:
|
|
136
186
|
return raw
|
|
137
187
|
|
|
@@ -140,7 +190,10 @@ class BulkEditForm(Screen[None]):
|
|
|
140
190
|
if include is not None:
|
|
141
191
|
if event.value:
|
|
142
192
|
self._included.add(include)
|
|
143
|
-
|
|
193
|
+
if include not in self._values:
|
|
194
|
+
seed = self._read_widget_value(include)
|
|
195
|
+
if seed is not _NO_SEED:
|
|
196
|
+
self._values[include] = seed
|
|
144
197
|
else:
|
|
145
198
|
self._included.discard(include)
|
|
146
199
|
return
|
|
@@ -150,6 +203,9 @@ class BulkEditForm(Screen[None]):
|
|
|
150
203
|
|
|
151
204
|
def _read_widget_value(self, name: str) -> Any:
|
|
152
205
|
spec = self._specs.get(name)
|
|
206
|
+
if self._fk_kinds.get(name) == "picker":
|
|
207
|
+
shared_id = self._shared.get(name)
|
|
208
|
+
return shared_id if shared_id is not None else _NO_SEED
|
|
153
209
|
if spec is not None and spec.kind == "select":
|
|
154
210
|
value = self.query_one(f"#field-{name}", Select).value
|
|
155
211
|
return None if value is Select.NULL else value
|
|
@@ -173,6 +229,27 @@ class BulkEditForm(Screen[None]):
|
|
|
173
229
|
name = self._strip(ident, "setnull-")
|
|
174
230
|
if name is not None:
|
|
175
231
|
self._values[name] = SET_NULL
|
|
232
|
+
# Drop any picked label so the diff can't show a name while the
|
|
233
|
+
# patch nulls the relation.
|
|
234
|
+
self._fk_labels.pop(name, None)
|
|
235
|
+
return
|
|
236
|
+
fk = self._strip(ident, "fk-")
|
|
237
|
+
if fk is not None:
|
|
238
|
+
self._open_picker(fk)
|
|
239
|
+
|
|
240
|
+
def _open_picker(self, name: str) -> None:
|
|
241
|
+
target = resolve_fk_target(name, self._fk_nested_value(name), self._model)
|
|
242
|
+
if target.list_op is None:
|
|
243
|
+
return
|
|
244
|
+
from nsc.tui.screens.record_picker import RecordPicker # noqa: PLC0415
|
|
245
|
+
|
|
246
|
+
def _stage(result: tuple[int, str] | None) -> None:
|
|
247
|
+
if result is not None:
|
|
248
|
+
self._values[name] = result[0]
|
|
249
|
+
self._fk_labels[name] = result[1]
|
|
250
|
+
self.query_one(f"#fk-{name}", Button).label = f"{name}: {result[1]}"
|
|
251
|
+
|
|
252
|
+
self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
|
|
176
253
|
|
|
177
254
|
def action_preview(self) -> None:
|
|
178
255
|
from nsc.tui.bulk import bulk_diff # noqa: PLC0415
|
|
@@ -180,7 +257,7 @@ class BulkEditForm(Screen[None]):
|
|
|
180
257
|
|
|
181
258
|
body = self._op.request_body
|
|
182
259
|
sensitive = body.sensitive_paths if body is not None else ()
|
|
183
|
-
changes = bulk_diff(self._selected, self.bulk_set, sensitive)
|
|
260
|
+
changes = bulk_diff(self._selected, self.bulk_set, sensitive, self._fk_labels)
|
|
184
261
|
|
|
185
262
|
def _on_confirm(confirmed: bool | None) -> None:
|
|
186
263
|
if confirmed:
|
|
@@ -23,7 +23,7 @@ from nsc.model.command_model import CommandModel, Resource
|
|
|
23
23
|
from nsc.output.flatten import flatten
|
|
24
24
|
from nsc.tui._bindings import textual_bindings
|
|
25
25
|
from nsc.tui.errors import api_error_message
|
|
26
|
-
from nsc.tui.fk import resolve_fk_target
|
|
26
|
+
from nsc.tui.fk import is_fk_value, resolve_fk_target
|
|
27
27
|
from nsc.tui.forms import SET_NULL, WidgetSpec, compute_patch, diff_rows, field_to_widget
|
|
28
28
|
from nsc.tui.relations import RelatedView, related_views
|
|
29
29
|
from nsc.tui.view import detail_path
|
|
@@ -69,6 +69,7 @@ class DetailScreen(Screen[None]):
|
|
|
69
69
|
self._specs: dict[str, WidgetSpec] = {}
|
|
70
70
|
self._rows: list[_FieldRow] = []
|
|
71
71
|
self.staged: dict[str, Any] = {}
|
|
72
|
+
self._fk_labels: dict[str, str] = {}
|
|
72
73
|
self._editing: str | None = None
|
|
73
74
|
self._relations: list[RelatedView] = related_views(model, resource_name)
|
|
74
75
|
self.title = f"{resource_name} #{record.get('id', '?')}"
|
|
@@ -129,8 +130,11 @@ class DetailScreen(Screen[None]):
|
|
|
129
130
|
staged = self.staged[row.name]
|
|
130
131
|
if staged is SET_NULL:
|
|
131
132
|
return "(null)"
|
|
132
|
-
# Never echo a just-typed secret
|
|
133
|
-
|
|
133
|
+
# Never echo a just-typed secret; a picked FK stages a bare id, so
|
|
134
|
+
# show its chosen label rather than the number.
|
|
135
|
+
return (
|
|
136
|
+
"****" if sensitive and staged != "" else self._fk_labels.get(row.name, str(staged))
|
|
137
|
+
)
|
|
134
138
|
if row.editable:
|
|
135
139
|
value = self._record.get(row.name)
|
|
136
140
|
if isinstance(value, dict):
|
|
@@ -199,6 +203,7 @@ class DetailScreen(Screen[None]):
|
|
|
199
203
|
def _stage(result: tuple[int, str] | None) -> None:
|
|
200
204
|
if result is not None:
|
|
201
205
|
self.staged[name] = result[0]
|
|
206
|
+
self._fk_labels[name] = result[1]
|
|
202
207
|
self._refresh_rows()
|
|
203
208
|
|
|
204
209
|
self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
|
|
@@ -236,9 +241,12 @@ class DetailScreen(Screen[None]):
|
|
|
236
241
|
self._table.focus()
|
|
237
242
|
|
|
238
243
|
def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
|
|
239
|
-
|
|
244
|
+
# Writable FK fields type as oneOf[int, brief] -> UNKNOWN -> `text`, so a
|
|
245
|
+
# `number`-only gate would miss real relations. Key off the record's
|
|
246
|
+
# nested object instead; exclude enum/bool/secret/float.
|
|
247
|
+
if spec.kind in ("select", "switch", "masked") or spec.is_float:
|
|
240
248
|
return False
|
|
241
|
-
return name.endswith("_id") or
|
|
249
|
+
return name.endswith("_id") or is_fk_value(self._record.get(name))
|
|
242
250
|
|
|
243
251
|
# --- save ------------------------------------------------------------
|
|
244
252
|
def action_save_all(self) -> None:
|
|
@@ -254,7 +262,7 @@ class DetailScreen(Screen[None]):
|
|
|
254
262
|
if confirmed:
|
|
255
263
|
self._apply_patch(patch)
|
|
256
264
|
|
|
257
|
-
rows = diff_rows(self._record, patch, self._sensitive)
|
|
265
|
+
rows = diff_rows(self._record, patch, self._sensitive, self._fk_labels)
|
|
258
266
|
self.app.push_screen(DiffModal(rows), _on_confirm)
|
|
259
267
|
|
|
260
268
|
def _apply_patch(self, patch: dict[str, Any]) -> None:
|
|
@@ -19,8 +19,15 @@ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
|
|
|
19
19
|
from nsc.model.command_model import CommandModel, Operation
|
|
20
20
|
from nsc.tui._bindings import textual_bindings
|
|
21
21
|
from nsc.tui.errors import api_error_message
|
|
22
|
-
from nsc.tui.fk import resolve_fk_target
|
|
23
|
-
from nsc.tui.forms import
|
|
22
|
+
from nsc.tui.fk import is_fk_value, resolve_fk_target
|
|
23
|
+
from nsc.tui.forms import (
|
|
24
|
+
SET_NULL,
|
|
25
|
+
WidgetSpec,
|
|
26
|
+
compute_patch,
|
|
27
|
+
diff_rows,
|
|
28
|
+
field_to_widget,
|
|
29
|
+
fk_display,
|
|
30
|
+
)
|
|
24
31
|
from nsc.tui.view import detail_path
|
|
25
32
|
|
|
26
33
|
|
|
@@ -75,6 +82,7 @@ class EditForm(Screen[None]):
|
|
|
75
82
|
self._op = operation
|
|
76
83
|
self._record = record
|
|
77
84
|
self._specs: dict[str, WidgetSpec] = {}
|
|
85
|
+
self._fk_labels: dict[str, str] = {}
|
|
78
86
|
self.staged: dict[str, Any] = {}
|
|
79
87
|
self._create_mode = operation.http_method.upper() == "POST"
|
|
80
88
|
if self._create_mode:
|
|
@@ -119,9 +127,12 @@ class EditForm(Screen[None]):
|
|
|
119
127
|
yield Input(value=text, password=spec.sensitive, id=f"field-{name}")
|
|
120
128
|
|
|
121
129
|
def _is_fk(self, name: str, spec: WidgetSpec) -> bool:
|
|
122
|
-
|
|
130
|
+
# Writable FK fields type as oneOf[int, brief] -> UNKNOWN -> `text`, so a
|
|
131
|
+
# `number`-only gate would miss real relations (role, site, tenant…). Key
|
|
132
|
+
# off the record's nested object instead; exclude enum/bool/secret/float.
|
|
133
|
+
if spec.kind in ("select", "switch", "masked") or spec.is_float:
|
|
123
134
|
return False
|
|
124
|
-
return name.endswith("_id") or
|
|
135
|
+
return name.endswith("_id") or is_fk_value(self._record.get(name))
|
|
125
136
|
|
|
126
137
|
def _compose_fk(self, name: str, value: Any) -> ComposeResult:
|
|
127
138
|
target = resolve_fk_target(name, self._record.get(name), self._model)
|
|
@@ -131,7 +142,12 @@ class EditForm(Screen[None]):
|
|
|
131
142
|
if target.hint:
|
|
132
143
|
yield Label(target.hint, classes="edit-fk-hint")
|
|
133
144
|
return
|
|
134
|
-
|
|
145
|
+
nested = self._record.get(name)
|
|
146
|
+
current = (
|
|
147
|
+
fk_display(nested)
|
|
148
|
+
if isinstance(nested, dict)
|
|
149
|
+
else ("" if value is None else str(value))
|
|
150
|
+
)
|
|
135
151
|
yield Button(f"{name}: {current}", id=f"fk-{name}", classes="edit-fk")
|
|
136
152
|
|
|
137
153
|
@staticmethod
|
|
@@ -181,7 +197,10 @@ class EditForm(Screen[None]):
|
|
|
181
197
|
self.action_save()
|
|
182
198
|
return
|
|
183
199
|
if ident.startswith("setnull-"):
|
|
184
|
-
|
|
200
|
+
field = ident.removeprefix("setnull-")
|
|
201
|
+
self.staged[field] = SET_NULL
|
|
202
|
+
# Drop any picked label so the diff can't show a name while nulling.
|
|
203
|
+
self._fk_labels.pop(field, None)
|
|
185
204
|
return
|
|
186
205
|
if ident.startswith("fk-"):
|
|
187
206
|
self._open_picker(ident.removeprefix("fk-"))
|
|
@@ -195,6 +214,7 @@ class EditForm(Screen[None]):
|
|
|
195
214
|
def _stage(result: tuple[int, str] | None) -> None:
|
|
196
215
|
if result is not None:
|
|
197
216
|
self.staged[name] = result[0]
|
|
217
|
+
self._fk_labels[name] = result[1]
|
|
198
218
|
self.query_one(f"#fk-{name}", Button).label = f"{name}: {result[1]}"
|
|
199
219
|
|
|
200
220
|
self.app.push_screen(RecordPicker(self._client, target.list_op, target.current_id), _stage)
|
|
@@ -212,7 +232,9 @@ class EditForm(Screen[None]):
|
|
|
212
232
|
if confirmed:
|
|
213
233
|
self._apply_patch(patch, sensitive)
|
|
214
234
|
|
|
215
|
-
self.app.push_screen(
|
|
235
|
+
self.app.push_screen(
|
|
236
|
+
DiffModal(diff_rows(self._record, patch, sensitive, self._fk_labels)), _on_confirm
|
|
237
|
+
)
|
|
216
238
|
|
|
217
239
|
def _apply_patch(self, patch: dict[str, Any], sensitive_paths: tuple[str, ...]) -> None:
|
|
218
240
|
try:
|
|
@@ -78,7 +78,15 @@ def test_fk_nested_dict_by_id_changes_when_id_differs() -> None:
|
|
|
78
78
|
selected = [{"id": 1, "site": {"id": 9, "name": "dc1"}}]
|
|
79
79
|
result = bulk_diff(selected, {"site": 3}, ())
|
|
80
80
|
assert result[0].patch == {"site": 3}
|
|
81
|
-
|
|
81
|
+
# Old value renders the FK's human label, not its numeric id.
|
|
82
|
+
assert result[0].rows == [DiffRow(field="site", old_display="dc1", new_display="3")]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_fk_new_display_override_renders_chosen_label() -> None:
|
|
86
|
+
selected = [{"id": 1, "site": {"id": 9, "name": "dc1"}}]
|
|
87
|
+
result = bulk_diff(selected, {"site": 3}, (), {"site": "dc2"})
|
|
88
|
+
assert result[0].patch == {"site": 3}
|
|
89
|
+
assert result[0].rows == [DiffRow(field="site", old_display="dc1", new_display="dc2")]
|
|
82
90
|
|
|
83
91
|
|
|
84
92
|
def test_set_null_on_already_null_record_yields_no_row() -> None:
|