diff --git a/.devcontainer/config/codespaces-configuration.py b/.devcontainer/config/codespaces-configuration.py index 84c1b669c..3ba387bf0 100644 --- a/.devcontainer/config/codespaces-configuration.py +++ b/.devcontainer/config/codespaces-configuration.py @@ -19,6 +19,8 @@ "127.0.0.1", "*", ] + # Development environment β€” logging config values is an accepted tradeoff here. + # CodeQL alert for this is dismissed intentionally. print(f"πŸ”— Codespaces detected: {codespace_name}") print(f"πŸ”’ CSRF Trusted Origins: {CSRF_TRUSTED_ORIGINS}") print(f"🌐 Allowed Hosts: {ALLOWED_HOSTS}") diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b64357a8f..a2be7efa2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,7 @@ > - [frontend.instructions.md](instructions/frontend.instructions.md) – applies to templates and static files > - [background-jobs.instructions.md](instructions/background-jobs.instructions.md) – applies to `jobs.py`, import views, and import utilities > - [sync.instructions.md](instructions/sync.instructions.md) – applies to sync views, base views, tables, and sync JS +> - [release.instructions.md](instructions/release.instructions.md) – applies to changelog, pyproject.toml, and `__init__.py` version bumps ## Architecture & Key Modules - Plugin hooks into NetBox (Django 5) under `netbox_librenms_plugin/`; respect NetBox plugin APIs (`navigation.py`, `urls.py`, `api/`). @@ -62,13 +63,36 @@ - `_get_safe_redirect_url(request)` validates referrer URLs to prevent open-redirect attacks. ### Permission Helpers for Background Jobs -- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details. +- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils/permissions.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details. ### API & Navigation Permissions - API endpoints use `LibreNMSPluginPermission` class in `api/views.py` (GET=view, others=change). - Navigation menu (`navigation.py`) has 3 groups: **Settings** (Plugin Settings, Interface Mappings), **Import** (LibreNMS Import), **Status Check** (Site & Location Sync, Device Status, VM Status). All items use `permissions=[PERM_VIEW_PLUGIN]`. - **Background job polling requires superuser** β€” non-superusers fall back to synchronous mode. See `background-jobs.instructions.md` for details. +## CodeQL & Security Patterns + +### Clearing CodeQL `py/reflected-xss` false positives +When a view builds an `HttpResponse` from Django-template-rendered HTML (decoded via `.content.decode()`), CodeQL traces `request β†’ template-render β†’ HttpResponse` as reflected XSS even though Django templates auto-escape all user values. + +**Correct fix:** use `format_html()` to compose the envelope and `mark_safe()` as a **trust assertion** on the inner HTML β€” CodeQL's Django taint model recognises this pattern and stops tracking the taint: + +```python +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +modal_html = some_view.get(request, pk).content.decode("utf-8") +oob = format_html('
{}
', mark_safe(modal_html)) +return HttpResponse(oob, content_type="text/html") +``` + +> **Important:** `mark_safe()` is a trust assertion, not a sanitizer β€” it tells Django "I guarantee this string is already safe HTML." Only use it when `modal_html` comes from a server-rendered Django view (whose templates auto-escape all user values). Never pass untrusted user input to `mark_safe()` β€” that would introduce real XSS. + +**Do NOT** use `# lgtm[py/reflected-xss]` β€” that is LGTM.com legacy syntax and is **not** honoured by GitHub's modern CodeQL Action. + +### URL converters +Always use `` (not ``) for numeric IDs in URL patterns. Django's `` converter auto-validates and returns 404 for non-integer values, eliminating the URL-parameter taint source that CodeQL otherwise flags. + ## When in Doubt - Check docs in `docs/development/` for structure, view inheritance, mixins, and template conventions before introducing new patterns. - Review the existing sync views (e.g., `views/sync/interfaces.py`) as reference implementations for data flow and caching patterns. diff --git a/.github/instructions/background-jobs.instructions.md b/.github/instructions/background-jobs.instructions.md index 405d9e038..405dd0a9f 100644 --- a/.github/instructions/background-jobs.instructions.md +++ b/.github/instructions/background-jobs.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "**/jobs.py,**/views/imports/**,**/import_utils.py,**/import_validation_helpers.py" +applyTo: "**/jobs.py,**/views/imports/**,**/import_utils/**,**/import_validation_helpers.py" description: Background job architecture, import workflow, and task management patterns --- @@ -69,13 +69,15 @@ Filter fields: `librenms_location`, `librenms_type`, `librenms_os`, `librenms_ho - **`DeviceVCDetailsView`** (GET) β€” renders VC member details via `htmx/device_vc_details.html`. - **`DeviceRoleUpdateView`**, **`DeviceClusterUpdateView`**, **`DeviceRackUpdateView`** (POST) β€” per-device dropdown updates. Apply selection to validation state and return re-rendered row via `render_device_row()`. -## Key Import Utilities (`import_utils.py`) -- `process_device_filters(filters, ...)` β€” fetches and validates devices from LibreNMS, returns list. -- `validate_device_for_import(device, ...)` β€” core validation function, produces validation state dict. -- `bulk_import_devices_shared(devices, user, ...)` β€” shared implementation between sync and background import. -- `bulk_import_vms(vm_imports, user, ...)` β€” VM import implementation. -- `fetch_device_with_cache(device_id, ...)` β€” retrieves/caches individual device data. -- Cache key functions: `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`. +## Key Import Utilities (`import_utils/` package) +`import_utils/` is a package; the `__init__.py` re-exports key functions so callers can still use `from import_utils import ...`. + +- `filters.py` β€” `process_device_filters(filters, ...)`, `fetch_device_with_cache(device_id, ...)`. +- `device_operations.py` β€” `validate_device_for_import(device, ...)`, `bulk_import_devices_shared(devices, user, ...)`. +- `vm_operations.py` β€” `bulk_import_vms(vm_imports, user, ...)`. +- `cache.py` β€” `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`. +- `permissions.py` β€” `check_user_permissions(user, permissions)`, `require_permissions(user, permissions, action_description)`. +- `virtual_chassis.py` β€” `create_virtual_chassis_with_members()`, `_sync_module_bay_counter()`. ## Validation Helpers (`import_validation_helpers.py`) Centralizes validation state mutation used by the role/cluster/rack update views: diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 4f02754b5..7d6795cb6 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -11,10 +11,11 @@ description: Frontend patterns for templates, HTMX, and static assets - All HTMX requests and `fetch()` calls must include a CSRF token. The standard pattern is `document.querySelector('[name=csrfmiddlewaretoken]').value` (from a hidden form input). The import JS also uses `getCookie('csrftoken')` as a fallback β€” prefer the hidden input approach for consistency. ## Modal Implementation -- Modals use Tabler (Bootstrap-like) but **without** `bootstrap.Modal` helpers. +- Modals try Bootstrap 5 native (`bootstrap.Modal`) first, falling back to manual DOM manipulation if unavailable. Both `librenms_sync.js` and `librenms_import.js` follow this pattern via `showModal()`/`hideModal()` helpers. - Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. - Do not reintroduce `data-bs-toggle` or duplicate modal IDs. - The import page uses `ModalManager` class and `filterModalManager` instanceβ€”always use this reference in fetch callbacks, not undefined `modalInstance` variables. +- Dismiss handlers (backdrop click, `data-bs-dismiss` buttons) are bound once per element to prevent stacking on repeated `showModal()` calls. ## JavaScript Fetch Patterns - Always check `response.ok` before processing fetch responses to catch HTTP errors. diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md new file mode 100644 index 000000000..1d0d29b6d --- /dev/null +++ b/.github/instructions/release.instructions.md @@ -0,0 +1,149 @@ +--- +applyTo: "**/changelog.md,**/pyproject.toml,**/__init__.py" +--- + +# Release Workflow + +This describes the standard release process for the NetBox LibreNMS Plugin. Follow these steps exactly when asked to create a new release. + +## Branch Strategy + +> **Both `develop` and `master` have branch protection.** All changes must go through pull requests β€” never push directly. + +### Standard Flow (used for all releases) + +1. **Create `release/X.Y.Z` branch** from develop +2. **Version bump commit** on the release branch +3. **PR `release/X.Y.Z` β†’ develop** (version bump PR) +4. **PR `develop` β†’ master** (release PR) β€” GitHub will auto-include any commits master is missing +5. **Tag `vX.Y.Z`** on master and create GitHub release +6. **Post-release:** master may now be ahead of develop (e.g. the merge commit). This resolves naturally β€” step 1 of the next release starts from the current develop, and the release PR (step 4) will reconcile any divergence. + +## Files to Update + +Three files must be updated in a single commit with message `Bump version to X.Y.Z and update changelog`: + +### `netbox_librenms_plugin/__init__.py` +```python +__version__ = "X.Y.Z" +``` + +### `pyproject.toml` +```toml +version = "X.Y.Z" +``` + +### `docs/changelog.md` +Prepend a new section at the top (after `# Changelog`). Use the current date in `YYYY-MM-DD` format. Categories used (pick only those that apply): +- `### New Features` +- `### Improvements` +- `### Fixes` +- `### Development` +- `### Documentation` + +Example: +```markdown +## X.Y.Z (YYYY-MM-DD) + +### Fixes +* Description of fix (#PR_NUMBER) +``` + +## Version Bump PR (release/X.Y.Z β†’ develop) + +**Title:** `Bump version to X.Y.Z and update changelog` + +**Body template:** +```markdown +## Summary +Bump version to X.Y.Z and update changelog for release. + +## Motivation / Problem +- Maintenance / cleanup + +Prepare release X.Y.Z with . + +## Scope of Change + +- Config / settings +- Docs only + +## How Was This Tested? + +- Not tested: version bump and changelog only + +## Risk Assessment +- No impact on existing users +- No code logic changes + +## Backwards Compatibility +- No breaking changes +``` + +## Release PR (develop β†’ master) + +**Title:** `Release X.Y.Z` + +**Body template:** +```markdown +## Summary +Release X.Y.Z β€” merge develop into master for PyPI release. + +## Motivation / Problem +- + + + +## Scope of Change + + + +## Changes + +- Bump version to X.Y.Z + +## How Was This Tested? + + + +## Risk Assessment + + +## Backwards Compatibility +- No breaking changes +``` + +## GitHub Release Text + +**Tag:** `vX.Y.Z` (create on master) +**Release title:** `vX.Y.Z` + +**Body template:** +```markdown +## + + + +### + +- (#PR_NUMBER) + +> ### Upgrade note +> - Standard [update process](https://github.com/bonzo81/netbox-librenms-plugin#update) applies. + +--- + +## All Changes +* by @ in https://github.com/bonzo81/netbox-librenms-plugin/pull/ +``` + +The "All Changes" section lists only the feature/fix PRs included in the release β€” not version bump or merge PRs. + +## Checklist + +- [ ] Version bumped in `__init__.py` and `pyproject.toml` +- [ ] Changelog updated in `docs/changelog.md` +- [ ] Version bump PR merged to develop +- [ ] Release PR merged to master +- [ ] Tag created on master +- [ ] GitHub release published diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 16b6e8858..beb3ffc7b 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -27,8 +27,10 @@ description: Testing patterns and conventions for the NetBox LibreNMS plugin ## Test Coverage by Module - `librenms_api.py` β†’ `test_librenms_api.py`, `test_librenms_api_helpers.py` -- `import_utils.py`, `import_validation_helpers.py`, `utils.py` β†’ `test_import_utils.py`, `test_import_validation_helpers.py`, `test_utils.py` +- `import_utils/` package (`filters.py`, `device_operations.py`, `vm_operations.py`, `cache.py`, `permissions.py`, `virtual_chassis.py`), `import_validation_helpers.py`, `utils.py` β†’ `test_import_utils.py`, `test_import_validation_helpers.py`, `test_utils.py` - `jobs.py`, `views/imports/list.py` β†’ `test_background_jobs.py` +- `import_utils/bulk_import.py` β†’ `test_coverage_bulk_import.py` +- Utility helpers (`utils.py` coverage tests) β†’ `test_coverage_utils.py` - Permission mixins, API permissions, constants β†’ `test_permissions.py` - VLAN API, mode detection, comparison, sync β†’ `test_vlan_sync.py` - `VlanAssignmentMixin`, VLAN enrichment β†’ `test_interface_vlan_sync.py` diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index f60697dcb..6f3a1669f 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -10,6 +10,9 @@ on: - master - develop +permissions: + contents: read + jobs: format-and-lint: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 1689127c4..8c4f9ab4a 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -8,6 +8,11 @@ on: tags: - '*' +# Default to read-only token; the publish-to-pypi job overrides this with the +# id-token: write permission required for PyPI trusted publishing. +permissions: + contents: read + jobs: build: name: Build distribution πŸ“¦ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ad3abf5a..23ddbb41b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,6 +10,9 @@ on: - master - develop +permissions: + contents: read + jobs: test-netbox: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 6abcffcb3..3a9402d9d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The plugin offers the following key features: ### Device Import Search and import devices from LibreNMS into NetBox with comprehensive validation and control: -- Filter devices by location, type, OS, hostname, or system name +- Filter devices by location, type, OS, hostname, system name, or hardware model - Validate import prerequisites (Site, Device Type, Device Role) - Smart matching for Sites, Device Types, and Platforms - Import as physical Devices or Virtual Machines @@ -21,13 +21,23 @@ Search and import devices from LibreNMS into NetBox with comprehensive validatio See the [Device Import Guide](docs/librenms_import/overview.md) for detailed usage instructions. +### Module / Inventory Sync +Synchronize physical inventory data from LibreNMS (via ENTITY-MIB) to NetBox installed modules: + +- Compare LibreNMS inventory items (line-cards, transceivers, fans, PSUs) against NetBox module bays +- Install, update, or skip individual modules directly from the sync table +- Rich mapping system: ModuleTypeMapping, ModuleBayMapping (with regex support), NormalizationRules, InventoryIgnoreRules, CarrierAutoInstallRules +- Virtual Chassis aware β€” inventory rows distributed across correct VC members + +See the [Module Sync Guide](docs/usage_tips/module_sync.md) and [Mapping Rules Guide](docs/usage_tips/mapping_rules.md) for details. + ### Device Field Sync Synchronize device information from LibreNMS to NetBox. The following device fields can be synchronized: - Device Name (with naming preference support) - Serial Number (including virtual chassis members) - Device Type -- Platform +- Platform (via [Platform Mappings](docs/usage_tips/mapping_rules.md#platform-mappings)) ### Interface Sync Pull interface data from Devices and Virtual Machines from LibreNMS into NetBox. The following interface attributes are synchronized: @@ -49,6 +59,8 @@ Create cable connection in NetBox from LibreNMS links data. ### IP Address Sync Create IP address in NetBox from LibreNMS device IP data. +An opt-in **Set Primary IP** toggle on the IP Address Sync tab can also set the device or VM Primary IP. + ### VLAN Sync - Create VLAN objects in NetBox from LibreNMS device VLAN data - Per-VLAN group assignment with scope-aware auto-selection @@ -57,7 +69,7 @@ Create IP address in NetBox from LibreNMS device IP data. - Add device to LibreNMS from Netbox device page. SNMP v2c and v3 are supported. -### Site & Location Synchronization +### Site & Location Sync The plugin also supports synchronizing NetBox Sites with LibreNMS locations: - Compare NetBox sites to LibreNMS location data - Create LibreNMS locations to match NetBox sites diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 000000000..bbd841303 --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,31 @@ +# Contrib: Example Mapping Files + +This directory contains example YAML mapping files for bulk import into the +NetBox LibreNMS Plugin. Each file can be imported via the plugin's bulk import +feature in the NetBox UI. + +## How to Import + +1. Navigate to the mapping page (e.g., **LibreNMS β†’ Device Type Mappings**) +2. Click the **Import** button (upload icon) in the top right +3. Select **YAML** format +4. Paste the contents of the relevant YAML file +5. Click **Submit** + +## Available Mappings + +| File | Description | +|------|-------------| +| `interface_type_mappings.yaml` | Maps LibreNMS interface types + speeds to NetBox interface types | +| `device_type_mappings.yaml` | Maps LibreNMS hardware strings to NetBox device types | +| `module_type_mappings.yaml` | Maps LibreNMS inventory model names to NetBox module types (incl. transceivers) | +| `module_bay_mappings.yaml` | Maps LibreNMS inventory container names to NetBox module bay names | +| `normalization_rules.yaml` | Regex-based string normalization applied before module type/bay lookups | +| `inventory_ignore_rules.yaml` | Suppresses phantom ENTITY-MIB entries (e.g. Cisco IOS-XR IDPROM artefacts) | +| `platform_mappings.yaml` | Maps LibreNMS platform strings to NetBox device platforms | + +## Customisation + +These files are **examples** β€” adjust values to match the device types, module +types, and interface types defined in your NetBox instance. The `netbox_*` +fields must reference objects that already exist in your NetBox. diff --git a/contrib/carrier_auto_install_rules.yaml b/contrib/carrier_auto_install_rules.yaml new file mode 100644 index 000000000..f92b6471d --- /dev/null +++ b/contrib/carrier_auto_install_rules.yaml @@ -0,0 +1,31 @@ +# Carrier Auto-Install Rules +# +# Suggest a holder/carrier ModuleType to install when LibreNMS reports orphan +# child modules (e.g. CPM cards, mezzanines, MDAs) that have no matching NetBox +# bay because their parent carrier was never installed in NetBox. +# +# Import via: LibreNMS Plugin β†’ Carrier Auto-Install Rules β†’ Import +# +# Fields: +# manufacturer: Optional manufacturer name (exact). Empty = any vendor. +# device_type_pattern: Optional regex on device_type.model. Empty = any model. +# librenms_child_class: Exact entPhysicalClass of the orphan child (e.g. cpmModule). +# librenms_child_name_pattern: Regex on entPhysicalName of the orphan child. +# netbox_bay_name_pattern: Regex on candidate empty device-level bay name. +# carrier_module_type: NetBox ModuleType model (slug-style model field) to install. +# description: Optional description. +# +# Patterns use Python re.fullmatch() β€” they must match the entire string. +# When the chassis has at least one empty bay matching netbox_bay_name_pattern, +# an "Install Carrier" button appears on the module sync page (suggest-only, +# never auto-installed). Multiple matching empty bays produce one button each. + +# Nokia 7750 SR-s chassis (e.g. SR-7s, SR-14s) report CPM cards in slots A/B +# but the physical CMA carrier that holds them is invisible to LibreNMS. +- manufacturer: Nokia + device_type_pattern: '^7750 SR-.*$' + librenms_child_class: cpmModule + librenms_child_name_pattern: '^Slot [AB]$' + netbox_bay_name_pattern: '^CMA$' + carrier_module_type: CMA2-7s + description: Install CMA2-7s carrier when CPM cards are reported orphaned diff --git a/contrib/device_type_mappings.yaml b/contrib/device_type_mappings.yaml new file mode 100644 index 000000000..2dec24152 --- /dev/null +++ b/contrib/device_type_mappings.yaml @@ -0,0 +1,73 @@ +# Device Type Mappings +# +# Maps LibreNMS hardware strings to NetBox device types. +# Import via: LibreNMS Plugin > Device Type Mappings > Import +# +# Fields: +# librenms_hardware β€” Hardware string exactly as shown in LibreNMS +# netbox_device_type β€” NetBox DeviceType (matched by model name or ID) +# description β€” Optional note +# +# The librenms_hardware value is matched case-insensitively. +# These mappings are checked BEFORE the built-in part_number/model fallback. + +# Juniper β€” LibreNMS reports verbose marketing names +- librenms_hardware: "Juniper MX480 Internet Backbone Router" + netbox_device_type: "MX480" + description: "Juniper MX480 chassis" + +- librenms_hardware: "Juniper MX960 Internet Backbone Router" + netbox_device_type: "MX960" + description: "Juniper MX960 chassis" + +- librenms_hardware: "Juniper MX304 Edge Router" + netbox_device_type: "MX304" + description: "Juniper MX304 edge router" + +- librenms_hardware: "JNP10008 [PTX10008]" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 core router" + +- librenms_hardware: "JNP7100-32C [ACX7100-32C]" + netbox_device_type: "ACX7100-32C" + description: "Juniper ACX7100-32C" + +- librenms_hardware: "JNP7024 [ACX7024]" + netbox_device_type: "ACX7024" + description: "Juniper ACX7024" + +- librenms_hardware: "Juniper JNP10008 Internet Backbone Router" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 (alternate hardware string)" + +- librenms_hardware: "Juniper VRR Internet Backbone Router" + netbox_device_type: "VRR" + description: "Juniper Virtual Route Reflector" + +# Nokia β€” model string matches directly in most cases +- librenms_hardware: "7750 SR-7s" + netbox_device_type: "7750 SR-7s" + description: "Nokia 7750 SR-7s service router" + +# Cisco β€” often matches by part_number but not always +- librenms_hardware: "WS-C4900M" + netbox_device_type: "WS-C4900M" + description: "Cisco Catalyst 4900M" + +# Cisco IOS XR +- librenms_hardware: "8201-SYS" + netbox_device_type: "8201" + description: "Cisco 8201 (hardware string differs from model)" + +# UfiSpace β€” LibreNMS reports SONiC/ONIE platform names +- librenms_hardware: "x86-64-ufispace-s9610-36d-r0" + netbox_device_type: "S9610-36D" + description: "UfiSpace S9610-36D" + +- librenms_hardware: "x86-64-ufispace-s9610-46dx-r0" + netbox_device_type: "S9610-46DX" + description: "UfiSpace S9610-46DX" + +- librenms_hardware: "x86-64-ufispace-s9700-53dx-r9" + netbox_device_type: "S9700-53DX" + description: "UfiSpace S9700-53DX" diff --git a/contrib/interface_type_mappings.yaml b/contrib/interface_type_mappings.yaml new file mode 100644 index 000000000..3f48cd94b --- /dev/null +++ b/contrib/interface_type_mappings.yaml @@ -0,0 +1,75 @@ +# Interface Type Mappings +# +# Maps LibreNMS interface types (and optional speeds) to NetBox interface types. +# Import via: LibreNMS Plugin > Interface Mappings > Import +# +# Fields: +# librenms_type β€” IANA ifType string from LibreNMS (e.g. ethernetCsmacd) +# librenms_speed β€” Speed in Kbps (optional, null matches any speed) +# netbox_type β€” NetBox InterfaceTypeChoices slug +# description β€” Optional note +# +# Common NetBox interface type slugs: +# 1000base-t, 10gbase-t, 10gbase-x-sfpp, 25gbase-x-sfp28, +# 40gbase-x-qsfpp, 100gbase-x-qsfp28, 400gbase-x-qsfpdd, +# ieee802.11ax, lag, virtual, other + +# WARNING: Speed-only matching cannot distinguish copper from fiber optics. +# For example, 1G ethernetCsmacd could be 1000base-t (copper), 1000base-x-sfp (fiber), +# or other media types. Review and adjust these mappings for your environment before +# importing β€” incorrect mappings will mislabel ports. + +- librenms_type: ethernetCsmacd + librenms_speed: 1000000 + netbox_type: 1000base-t + description: "1G Ethernet copper" + +- librenms_type: ethernetCsmacd + librenms_speed: 10000000 + netbox_type: 10gbase-x-sfpp + description: "10G Ethernet SFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 25000000 + netbox_type: 25gbase-x-sfp28 + description: "25G Ethernet SFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 40000000 + netbox_type: 40gbase-x-qsfpp + description: "40G Ethernet QSFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 100000000 + netbox_type: 100gbase-x-qsfp28 + description: "100G Ethernet QSFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 400000000 + netbox_type: 400gbase-x-qsfpdd + description: "400G Ethernet QSFP-DD" + +- librenms_type: ieee8023adLag + librenms_speed: + netbox_type: lag + description: "LACP/LAG aggregation" + +- librenms_type: propVirtual + librenms_speed: + netbox_type: virtual + description: "Virtual/loopback interface" + +- librenms_type: softwareLoopback + librenms_speed: + netbox_type: virtual + description: "Software loopback" + +- librenms_type: tunnel + librenms_speed: + netbox_type: virtual + description: "Tunnel interface" + +- librenms_type: l2vlan + librenms_speed: + netbox_type: virtual + description: "VLAN interface" diff --git a/contrib/inventory_ignore_rules.yaml b/contrib/inventory_ignore_rules.yaml new file mode 100644 index 000000000..e56b7c997 --- /dev/null +++ b/contrib/inventory_ignore_rules.yaml @@ -0,0 +1,94 @@ +# Inventory Ignore Rules β€” filter ENTITY-MIB items during module sync +# +# Two actions are supported: +# skip β€” remove the item from the sync table entirely +# transparent β€” hide the item's row but promote its ENTITY-MIB children to +# device-level bay matching (use for embedded/fixed-chassis modules) +# +# Match types: +# ends_with | starts_with | contains | regex +# β€” compare entPhysicalName +# serial_matches_device β€” compare entPhysicalSerialNum to the NetBox device's +# own serial number (no pattern needed) +# +# Import via: LibreNMS Plugin β†’ Settings β†’ Inventory Ignore Rules β†’ Import +# +# Additional fields: +# require_serial_match_parent: +# true β€” (name-based rules only) only apply if the item's serial matches +# any ancestor entity's serial in the ENTITY-MIB tree +# false β€” apply unconditionally on name match alone +# enabled: true | false +# description: optional notes +# +# Serial-match ancestor walk (name-based rules only): +# When require_serial_match_parent is true the plugin walks up the ENTITY-MIB +# ancestor chain until it finds a non-empty serial. If that serial equals the +# item's serial the rule fires. This handles multi-level hierarchies, e.g. +# Cisco IOS-XR: IDPROM β†’ Mother Board [empty serial] β†’ RP module. +# +# Both rules below are also created automatically by migration 0010. Import them +# only if you wiped the table or need to replicate settings across instances. + +# ─── Cisco IOS-XR β€” IDPROM entries (action=skip) ───────────────────────────── +# IOS-XR reports each hardware component's EEPROM as a child entity whose name +# ends in "-IDPROM". These share the same model+serial as the parent and are not +# installable hardware. +# +# Hierarchy example (Cisco 8201-SYS): +# 0/RP0/CPU0 (serial FOC2418NHRK) +# └── 0/RP0/CPU0-Mother Board (serial empty) +# └── 0/RP0/CPU0-Base Board IDPROM (serial FOC2418NHRK) ← SKIP +# Optics0/0/0/0 (serial SN123) +# └── Optics0/0/0/0-IDPROM (serial SN123) ← SKIP + +- name: "Cisco IOS-XR IDPROM entries" + match_type: ends_with + pattern: "IDPROM" + action: skip + require_serial_match_parent: true + enabled: true + description: > + Cisco IOS-XR reports every hardware component's EEPROM as a child entity + whose entPhysicalName ends in "IDPROM". These entries duplicate the parent + module's serial number and are not real installable modules. + The serial-match guard ensures only genuine EEPROM duplicates are skipped β€” + a module whose name happens to end in "IDPROM" but has a different serial + will not be filtered. + +# ─── Fixed-chassis embedded RP (action=transparent) ────────────────────────── +# Fixed-form routers (e.g. Cisco 8201-SYS, 8101-32FH, Juniper PTX10001-36MR) +# report their built-in RP/system-board as an ENTITY-MIB module whose serial +# number equals the chassis/device serial. The RP is NOT a removable FRU β€” +# it IS the device itself. Marking it "transparent" hides the RP row but lets +# its ENTITY-MIB children (transceivers, fans, PSUs) fall through to device- +# level bay matching. +# +# Detection signal: entPhysicalSerialNum == NetBox Device.serial +# +# Hierarchy for Cisco 8201-SYS (device serial = FOC2418NHRK): +# Rack 0-Control Card Slot 0 (container) +# └── 0/RP0/CPU0 (serial FOC2418NHRK) ← TRANSPARENT (= device serial) +# └── Optics Controller containers +# └── 0/RP0/CPU0-QSFP bay N +# └── Optics0/0/0/N (transceiver) ← becomes device-level bay match +# +# The 8201-SYS device type should have device-level bays for: +# Optics0/0/0/0–23 (400GE QSFP-DD) +# HundredGigE0/0/0/24–35 (100GE QSFP28) +# 0/FT0–4 (fans) +# 0/PM0–1 (PSUs) +# NO bay for 0/RP0/CPU0 β€” the RP is the device, not a pluggable module. + +- name: "Embedded RP / fixed-chassis system board" + match_type: serial_matches_device + pattern: "" + action: transparent + require_serial_match_parent: false + enabled: true + description: > + Fixed-form routers report the built-in RP as an ENTITY-MIB module whose + serial number equals the device's own serial. Marking it transparent hides + the RP row in the sync table while promoting its children (transceivers, + fans, PSUs) to device-level bay matching. No pattern is needed β€” detection + is purely serial-based. diff --git a/contrib/module_bay_mappings.yaml b/contrib/module_bay_mappings.yaml new file mode 100644 index 000000000..27c5a86df --- /dev/null +++ b/contrib/module_bay_mappings.yaml @@ -0,0 +1,221 @@ +# Module Bay Mappings - Map LibreNMS inventory container names to NetBox module bay names +# +# These mappings replace heuristic matching between LibreNMS inventory and NetBox module bays. +# Import via: LibreNMS Plugin β†’ Module Bay Mappings β†’ Import +# +# Fields: +# librenms_name: LibreNMS entPhysicalName or container name (exact match or regex) +# librenms_class: Optional entPhysicalClass filter (powerSupply, fan, module, etc.) +# Leave empty for class-independent mappings +# netbox_bay_name: Target NetBox module bay name (supports \1, \2 backreferences with regex) +# is_regex: Set to true to treat librenms_name as a Python regex pattern +# manufacturer: Optional NetBox Manufacturer name to scope this mapping to a single +# vendor (matches the device's device_type.manufacturer). Leave empty +# for vendor-independent mappings; manufacturer-scoped rows win over +# global ones for matching devices. +# description: Optional description +# +# Regex patterns use Python re.fullmatch() β€” the pattern must match the entire string. +# Backreferences (\1, \2) in netbox_bay_name reference capture groups in the pattern. + +# ─── Regex Patterns ────────────────────────────────────────────────────────── +# These patterns replace many individual exact-match entries. + +# Arcos/UfiSpace: sfpN β†’ Transceiver N (covers sfp0 through sfp53+) +- librenms_name: "^sfp(\\d+)$" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Arcos sfpN β†’ Transceiver N" + +# Cisco X2: Port Container slot/port β†’ X2 Port port +- librenms_name: "^Port Container (\\d+)/(\\d+)$" + netbox_bay_name: "X2 Port \\2" + is_regex: true + description: "Cisco X2 Port Container β†’ X2 Port N" + +# Cisco modules: Linecard/Supervisor(slot N) β†’ Slot N +- librenms_name: "^Linecard\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Linecard slot β†’ Slot N" +- librenms_name: "^Supervisor\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Supervisor slot β†’ Slot N" + +# Generic power supplies and fans +- librenms_name: "^Power Supply (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PS\\1" + is_regex: true + description: "Power Supply N β†’ PSN" +- librenms_name: "^FanTray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan Tray \\1" + is_regex: true + description: "FanTray N β†’ Fan Tray N" + +# Nokia 7750 SR chassis fans and power modules +- librenms_name: "^Chassis 1 Fan (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Nokia chassis fan β†’ Fan N" +- librenms_name: "^Chassis 1 PowShelf 1 PM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PM \\1" + is_regex: true + description: "Nokia power module β†’ PM N" + +# Nokia MDA and XIOM sub-module bays +# Bay names resolve from {module}/N templates: IOM Slot 1 pos=1 β†’ bay {module}/1 = 1/1 +- librenms_name: "^MDA (\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "\\1/\\2" + is_regex: true + description: "Nokia MDA N/M β†’ N/M (matches {module}/M on IOM)" +- librenms_name: "^XIOM (\\d+)/x(\\d+)$" + librenms_class: "xioModule" + netbox_bay_name: "\\1/x\\2" + is_regex: true + description: "Nokia XIOM N/xM β†’ N/xM (matches {module}/xM on IOM)" +- librenms_name: "^MDA (\\d+)/x(\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "x\\2/\\3" + is_regex: true + description: "Nokia MDA in XIOM N/xP/Q β†’ xP/Q (matches {module}/Q on XIOM)" + +# Nokia transceiver connector bays +# LibreNMS ifName "1/1/c1" (slot/mda/connector) β†’ NetBox bay "1/c1" +# ({module} on MDA resolves to position, stripping the slot prefix) +- librenms_name: "^(\\d+)/(\\d+)/(c\\d+)$" + librenms_class: "port" + netbox_bay_name: "\\2/\\3" + is_regex: true + description: "Nokia transceiver slot/mda/cN β†’ mda-pos/cN" +# LibreNMS ifName "2/x1/1/c2" (slot/xiom/mda/connector) β†’ NetBox bay "1/c2" +- librenms_name: "^(\\d+)/x(\\d+)/(\\d+)/(c\\d+)$" + librenms_class: "port" + netbox_bay_name: "\\3/\\4" + is_regex: true + description: "Nokia XIOM transceiver slot/xiom/mda/cN β†’ mda-pos/cN" + +# Juniper MX transceiver bays +# LibreNMS entPhysicalDescr format: "SFP+-10G-SR @ {fpc}/{pic}/{port}" +# NetBox MPC-3D-16XGE-SFPP bay format: "Transceiver {pic}/{port}" +- librenms_name: "^[^@]+ @ \\d+/(\\d+)/(\\d+)$" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1/\\2" + is_regex: true + manufacturer: "Juniper" + description: "Juniper MX SFP+ @ fpc/pic/port β†’ Transceiver pic/port (vendor-scoped to avoid clashes with other 3-segment naming schemes)" + +# ─── Exact Match Entries ───────────────────────────────────────────────────── +# These are for special cases where names don't follow a regex pattern. + +# Nokia CPM slots +- librenms_name: "Slot A" + librenms_class: "cpmModule" + netbox_bay_name: "Slot A" + description: "Nokia CPM slot A" +- librenms_name: "Slot B" + librenms_class: "cpmModule" + netbox_bay_name: "Slot B" + description: "Nokia CPM slot B" +- librenms_name: "SR-7s 2 CPM mini" + librenms_class: "cpmCarrier" + netbox_bay_name: "CMA" + description: "Nokia CMA2-7s CPM carrier bracket" + +# Juniper fixed-form devices +- librenms_name: "PSM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper PSU slot 0" +- librenms_name: "PSM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper PSU slot 1" + +# Juniper chassis devices (PTX10008 etc.): PSM β†’ PEM +# Regex runs after exact matches, so PSM 0/1 β†’ PSU 0/1 above takes priority for ACX +- librenms_name: "^PSM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PEM \\1" + is_regex: true + description: "Juniper chassis PSM N β†’ PEM N" + +# Juniper FPC container: "FPC: @ N/*/*" β†’ FPC N +- librenms_name: "^FPC: .+ @ (\\d+)/\\*/\\*$" + librenms_class: "container" + netbox_bay_name: "FPC \\1" + is_regex: true + description: "Juniper FPC container description β†’ FPC N" + +# Juniper transceivers: " @ slot/pic/port" description β†’ Transceiver slot/pic/port +- librenms_name: "^.+ @ (\\d+/\\d+/\\d+)$" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Juniper transceiver description β†’ Transceiver slot/pic/port" + +# Juniper fan trays: "Fan Tray N" β†’ "Fan N" (ACX7100, etc.) +# Runs after exact match, so "Fan Tray 0" β†’ "Fan Tray" (ACX7024) still works +- librenms_name: "^Fan Tray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Juniper Fan Tray N β†’ Fan N (ACX7100 etc.)" + +# Juniper MX304: PEM β†’ PSU (MX304 bays are named PSU, not PEM) +- librenms_name: "PEM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper MX304 PEM 0 β†’ PSU 0" +- librenms_name: "PEM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper MX304 PEM 1 β†’ PSU 1" + +- librenms_name: "Fan Tray 0" + librenms_class: "fan" + netbox_bay_name: "Fan Tray" + description: "Juniper single fan tray (ACX7024)" + +# Juniper PTX10008: SIB β†’ CB (Switch Interface Board β†’ Component Board slot) +- librenms_name: "SIB 0" + librenms_class: "container" + netbox_bay_name: "CB 0" + description: "Juniper PTX10008 SIB 0 β†’ CB 0" +- librenms_name: "SIB 1" + librenms_class: "container" + netbox_bay_name: "CB 1" + description: "Juniper PTX10008 SIB 1 β†’ CB 1" +- librenms_name: "SIB 2" + librenms_class: "container" + netbox_bay_name: "CB 2" + description: "Juniper PTX10008 SIB 2 β†’ CB 2" +- librenms_name: "SIB 3" + librenms_class: "container" + netbox_bay_name: "CB 3" + description: "Juniper PTX10008 SIB 3 β†’ CB 3" +- librenms_name: "SIB 4" + librenms_class: "container" + netbox_bay_name: "CB 4" + description: "Juniper PTX10008 SIB 4 β†’ CB 4" +- librenms_name: "SIB 5" + librenms_class: "container" + netbox_bay_name: "CB 5" + description: "Juniper PTX10008 SIB 5 β†’ CB 5" + +# Arcos power supplies +- librenms_name: "psu0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Arcos PSU slot 0" +- librenms_name: "psu1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Arcos PSU slot 1" diff --git a/contrib/module_type_mappings.yaml b/contrib/module_type_mappings.yaml new file mode 100644 index 000000000..c0346aba0 --- /dev/null +++ b/contrib/module_type_mappings.yaml @@ -0,0 +1,339 @@ +# Module Type Mappings +# +# Maps LibreNMS inventory model names (entPhysicalModelName) to NetBox module types. +# Import via: LibreNMS Plugin > Module Type Mappings > Import +# +# Fields: +# librenms_model β€” Model name from LibreNMS SNMP inventory +# manufacturer β€” Optional NetBox Manufacturer name; leave blank for vendor-agnostic +# mappings, set when the same model string is reused across vendors. +# netbox_module_type β€” NetBox ModuleType (matched by model name or ID) +# description β€” Optional note +# +# These mappings are checked FIRST. If no mapping exists, the plugin falls back +# to exact model name and part_number matching against NetBox module types. + +# ─── Cisco Catalyst 4900M ──────────────────────────────────────────────────── + +- librenms_model: "WS-X4908-10GE" + netbox_module_type: "WS-X4908-10GE" + description: "Cisco 8-port 10G X2 line card" + +- librenms_model: "WS-X4992" + netbox_module_type: "WS-X4992" + description: "Cisco 48-port 10/100/1000 line card" + +- librenms_model: "PWR-C49M-1000AC" + netbox_module_type: "PWR-C49M-1000AC" + description: "Cisco 1000W AC power supply" + +- librenms_model: "CVR-X2-SFP" + netbox_module_type: "CVR-X2-SFP" + description: "Cisco X2-to-SFP converter" + +# ─── Juniper Backplane ─────────────────────────────────────────────────────── + +- librenms_model: "710-017414" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by part number)" + +- librenms_model: "CHAS-BP-MX480-S" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by name)" + +# ─── Juniper FPC / Line Card Mappings ──────────────────────────────────────── +# Juniper FPCs use 750-xxxxxx part numbers as entPhysicalModelName. + +- librenms_model: "750-018124" + netbox_module_type: "DPCE-R-4XGE-XFP" + description: "Juniper DPCE 4-port 10G XFP DPC" + +- librenms_model: "750-022765" + netbox_module_type: "DPCE-R-20GE-2XGE" + description: "Juniper DPCE 20x1G + 2x10G combo DPC" + +- librenms_model: "750-028467" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+" + +- librenms_model: "750-056519" + netbox_module_type: "MPC7E-MRATE" + description: "Juniper MPC7E 12-port QSFP+/QSFP28 multirate" + +- librenms_model: "750-062581" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+ (variant PN)" + +# ─── Juniper Power Supply Mappings ─────────────────────────────────────────── + +- librenms_model: "740-029970" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU" + +- librenms_model: "740-063046" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU (variant PN)" + +- librenms_model: "740-027760" + netbox_module_type: "PWR-MX960-4100-AC" + description: "Juniper MX960 4100W AC PSU" + +- librenms_model: "740-110419" + netbox_module_type: "JNP-PWR2200-AC" + description: "Juniper MX304 2200W AC PSU" + +# Removed: JPSU-1600W-1UACAFO β€” exact model match, no mapping needed + +# ─── Juniper Fan Tray Mappings ─────────────────────────────────────────────── + +- librenms_model: "740-031521" + netbox_module_type: "FFANTRAY-MX960-HC" + description: "Juniper MX960 high-capacity fan tray" + +- librenms_model: "760-126744" + netbox_module_type: "JNP-FAN-2RU" + description: "Juniper MX304 2RU fan tray" + +# Removed: JNP7100-FAN1RU-AO β€” exact model match, no mapping needed + +# ─── Nokia 7750 SR-7s Module Mappings ──────────────────────────────────────── +# Nokia 3HE part numbers are handled by NormalizationRule: +# 1. Strip extra text (e.g. "3HE10550AARA01 NOK IPU3BFUEAA" β†’ "3HE10550AARA01") +# 2. Strip revision suffix (e.g. "3HE10550AARA01" β†’ "3HE10550AA") +# The normalized value matches the part_number field on NetBox ModuleTypes. +# No explicit Nokia mappings are needed. + +# ─── Transceiver Mappings: Juniper Part Numbers ───────────────────────────── +# Juniper-qualified optics use 740-xxxxxx part numbers regardless of OEM vendor. + +- librenms_model: "740-013111" + netbox_module_type: "SFP-1G-T" + description: "Juniper SFP 1000BASE-T copper" + +- librenms_model: "740-021308" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031850" + netbox_module_type: "SFP-1G-LX" + description: "Juniper SFP 1000BASE-LX 10km" + +- librenms_model: "740-031980" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031981" + netbox_module_type: "SFP-10G-LR" + description: "Juniper SFP+ 10G-LR" + +- librenms_model: "740-047682" + netbox_module_type: "CFP-100G-LR4" + description: "Juniper CFP 100G-LR4" + +- librenms_model: "740-054050" + netbox_module_type: "QSFP-4X10G-LR" + description: "Juniper QSFP+ 4x10G-LR" + +- librenms_model: "740-054053" + netbox_module_type: "QSFP-4X10G-SR" + description: "Juniper QSFP+ 4x10G-SR" + +- librenms_model: "740-058732" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-061405" + netbox_module_type: "QSFP-100G-SR4" + description: "Juniper QSFP28 100G-SR4" + +- librenms_model: "740-061409" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-079871" + netbox_module_type: "QSFP-DD-2X100G-LR4" + description: "Juniper QSFP-DD 2x100G-LR4" + +- librenms_model: "740-082823" + netbox_module_type: "QSFP-DD-400G-LR8" + description: "Juniper QSFP-DD 400G-LR8" + +- librenms_model: "740-085349" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Juniper QSFP-DD 400G-FR4" + +- librenms_model: "740-085351" + netbox_module_type: "QSFP-DD-400G-DR4" + description: "Juniper QSFP-DD 400G-DR4" + +- librenms_model: "740-096176" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "Juniper QSFP-DD 400G-LR4 (10km variant)" + +- librenms_model: "740-131169" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Juniper QSFP-DD 400G-ZR-M" + +- librenms_model: "740-151745" + netbox_module_type: "QSFP-DD-400G-ZR-M-HP" + description: "Juniper QSFP-DD 400G-ZR-M high-power" + +- librenms_model: "740-172665" + netbox_module_type: "QSFP-100G-ZR" + description: "Juniper QSFP28 100G-ZR" + +# ─── Transceiver Mappings: Finisar / II-VI / Coherent ──────────────────────── +# These are BASE part numbers (after normalization strips customer suffixes). +# See contrib/normalization_rules.yaml for the Finisar suffix-stripping rule. + +- librenms_model: "FTLC1154RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4" + +- librenms_model: "FTLC1151RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (variant)" + +- librenms_model: "FTLX1474D3BCL" + netbox_module_type: "SFP-10G-LR" + description: "Finisar SFP+ 10G-LR" + +- librenms_model: "FTCD3323R1PCL" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Finisar/II-VI QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Cisco / Cisco-branded OEM ───────────────────────── + +- librenms_model: "X2-10GB-LR" + netbox_module_type: "X2-10GB-LR" + description: "Cisco X2 10G-LR" + +- librenms_model: "X2-10GB-SR" + netbox_module_type: "X2-10GB-SR" + description: "Cisco X2 10G-SR" + +- librenms_model: "GLC-T" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper" + +- librenms_model: "GLC-TE" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper (extended temp)" + +- librenms_model: "SPP5200LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SPP5310LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SFBR-709SMZ-CS1" + netbox_module_type: "SFP-10G-SR" + description: "Cisco-branded Avago/Broadcom SFP+ 10G-SR" + +- librenms_model: "DP04QSDD-HE0" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco/Acacia QSFP-DD 400G-ZR+ coherent" + +- librenms_model: "QDD-400G-ZRP-S" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco QSFP-DD 400G-ZR+" + +- librenms_model: "QDD-400G-ZR4-S" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Cisco QSFP-DD 400G-ZR" + +# ─── Transceiver Mappings: Ciena ───────────────────────────────────────────── + +- librenms_model: "180-3530-900" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena WaveLogic 5 Nano QSFP-DD 400ZR" + +- librenms_model: "176-3360-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "176-3530-901" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena QSFP-DD 400G-ZR coherent" + +- librenms_model: "176-3590-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +# ─── Transceiver Mappings: T1 Nexus ───────────────────────────────────────── + +- librenms_model: "T1-QDD-400G-LR4" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "T1 Nexus QSFP-DD 400G-LR4" + +- librenms_model: "T1-QDD-400G-FR4" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "T1 Nexus QSFP-DD 400G-FR4" + +- librenms_model: "T1-QSFP28-LR4" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4" + +- librenms_model: "100G-LR4_A3" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4 (rev A3)" + +# ─── Transceiver Mappings: Innolight ──────────────────────────────────────── + +- librenms_model: "T-DQ4CNT-NCN" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Innolight QSFP-DD 400G-FR4" + +# ─── Transceiver Mappings: FS.com ──────────────────────────────────────────── + +- librenms_model: "Q28-PC03" + netbox_module_type: "QSFP28-100G-CU3M" + description: "FS.com QSFP28 100G passive DAC 3m" + +# ─── Transceiver Mappings: ProLabs ─────────────────────────────────────────── + +- librenms_model: "Q28LR431-10-IN" + netbox_module_type: "QSFP-100G-LR4" + description: "ProLabs QSFP28 100G-LR4 10km" + +# ─── Transceiver Mappings: Arcos Fixed-Port Part Numbers ───────────────────── + +- librenms_model: "SP7041-TE" + netbox_module_type: "SFP-1G-T" + description: "SFP 1000BASE-T copper (Arcos platform)" + +# ─── Transceiver Mappings: LeGrand Innolight ───────────────────────────────── + +- librenms_model: "LGI-FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "LeGrand-branded Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Additional Finisar Variants ────────────────────── +# Some transceivers have customer-code suffixes that normalization may not handle. +# Add direct mappings as fallback. + +- librenms_model: "FTLC1151RDPL-CN" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (CN customer code)" + +- librenms_model: "FTLC1154RDPL-A5" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (A5 customer code)" + +# ─── Unknown / Unidentified Part Numbers ───────────────────────────────────── +# When LibreNMS reports an opaque vendor part code (no recognisable prefix), the +# safest pattern is a *manufacturer-scoped* mapping so the same code in a different +# vendor's inventory doesn't accidentally collide. Leave the example below +# commented-out as a template β€” uncomment and fill in your manufacturer/module type +# only after confirming the part code on real hardware. +# +# - librenms_model: "1F3QAA" +# manufacturer: "Finisar" # scope to the actual vendor that ships this code +# netbox_module_type: "QSFP-100G-LR4" +# description: "Vendor-specific QSFP28 100G mapping β€” verify before importing" diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml new file mode 100644 index 000000000..6938fad17 --- /dev/null +++ b/contrib/normalization_rules.yaml @@ -0,0 +1,61 @@ +# Normalization Rules β€” Examples +# +# Regex-based string transformations applied before module type, device type, +# or module bay matching. Rules run in priority order (lower first); each +# rule's output feeds the next. +# +# Import via: LibreNMS β†’ Normalization Rules β†’ Import β†’ YAML +# +# Fields: +# scope β€” module_type, device_type, or module_bay +# manufacturer β€” Optional manufacturer name (must exist in NetBox). +# When set, the rule only fires for that manufacturer. +# match_pattern β€” Python regex (re.sub pattern) +# replacement β€” Replacement string (supports \1, \2 back-references) +# priority β€” Lower values run first (default 100) +# description β€” Optional note + +# ── Nokia revision suffix stripping ────────────────────────────────────────── +# Nokia ENTITY-MIB reports module/transceiver models with 4-char revision +# suffixes (e.g. 3HE16474AARA01). NetBox module types use the base part +# number (3HE16474AA). This rule strips the suffix before matching. +# +# Captures the 10-char base (3HE + 5 digits + 2 quality-tier letters), +# discards the 2-letter revision code + 2-digit build number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE[0-9]{5}[A-Z]{2})[A-Z]{2}\\d{2}$" + replacement: "\\1" + priority: 100 + description: "Strip Nokia revision suffixes (e.g. RA01, RB01, RG01) from ENTITY-MIB model strings" + +# ── Finisar / II-VI / Coherent suffix stripping ───────────────────────────── +# Finisar part numbers have customer-specific suffixes after a hyphen: +# FTLC1154RDPL-A5 (original Finisar) +# FTLC1154RDPL-C (Prolabs compatible) +# FTLX1474D3BCL-C1 (Cisco-coded Finisar) +# This rule strips everything after the last hyphen for FT... models. +- scope: module_type + match_pattern: "^(FT[A-Z0-9]+)-[A-Z0-9]+$" + replacement: "\\1" + priority: 100 + description: "Strip Finisar/II-VI customer suffixes (-A5, -C, -CN, -C1, etc.)" + +# ── Prolabs LGI- prefix stripping ─────────────────────────────────────────── +# Prolabs-compatible optics sometimes prepend LGI- to the OEM part number: +# LGI-FTLC9152RGPL β†’ FTLC9152RGPL +- scope: module_type + match_pattern: "^LGI-(.+)$" + replacement: "\\1" + priority: 50 + description: "Strip Prolabs LGI- prefix from OEM part numbers" + +# ── Nokia transceiver model field cleanup ──────────────────────────────────── +# Nokia transceiver API sometimes returns model strings with trailing vendor +# info: "3HE10550AARA01 NOK IPU3BFUEAA" β€” extract just the part number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE[A-Z0-9]+)\\s+.*$" + replacement: "\\1" + priority: 50 + description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)" diff --git a/contrib/platform_mappings.yaml b/contrib/platform_mappings.yaml new file mode 100644 index 000000000..f599a6150 --- /dev/null +++ b/contrib/platform_mappings.yaml @@ -0,0 +1,36 @@ +# Platform Mappings β€” Examples +# +# Maps LibreNMS OS strings to NetBox Platforms. +# The librenms_os value is matched exactly (case-insensitive) against the +# LibreNMS `os` field returned for each device. +# +# Import via: LibreNMS β†’ Platform Mappings β†’ Import β†’ YAML +# +# Fields: +# librenms_os β€” LibreNMS OS identifier (e.g. "ios", "nxos", "junos") +# netbox_platform β€” NetBox Platform name (must exist in NetBox) + +# ── Cisco ───────────────────────────────────────────────────────────────────── +- librenms_os: ios + netbox_platform: Cisco IOS + +- librenms_os: iosxe + netbox_platform: Cisco IOS-XE + +- librenms_os: iosxr + netbox_platform: Cisco IOS-XR + +- librenms_os: nxos + netbox_platform: Cisco NX-OS + +# ── Juniper ─────────────────────────────────────────────────────────────────── +- librenms_os: junos + netbox_platform: Juniper Junos + +# ── Arista ──────────────────────────────────────────────────────────────────── +- librenms_os: eos + netbox_platform: Arista EOS + +# ── Linux / generic ─────────────────────────────────────────────────────────── +- librenms_os: linux + netbox_platform: Linux diff --git a/docs/README.md b/docs/README.md index 4acfead73..6e4541f20 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,17 @@ Search and import devices from LibreNMS into NetBox with comprehensive validatio See the [Device Import Guide](librenms_import/overview.md) for detailed usage instructions. +### Module / Inventory Sync + +Synchronize physical inventory data from LibreNMS (via ENTITY-MIB) to NetBox installed modules: + +* Compare LibreNMS inventory items (line-cards, transceivers, fans, PSUs) against NetBox module bays +* Install, update, or skip individual modules directly from the sync table +* Rich mapping system: ModuleTypeMapping, ModuleBayMapping (with regex support), NormalizationRules, InventoryIgnoreRules, CarrierAutoInstallRules +* Virtual Chassis aware β€” inventory rows distributed across correct VC members + +See the [Module Sync Guide](usage_tips/module_sync.md) and [Mapping Rules Guide](usage_tips/mapping_rules.md) for details. + ### Device Field Sync Synchronize device information from LibreNMS to NetBox. The following device fields can be synchronized: @@ -29,7 +40,7 @@ Synchronize device information from LibreNMS to NetBox. The following device fie * Device Name (with naming preference support) * Serial Number (including virtual chassis members) * Device Type -* Platform +* Platform (via [Platform Mappings](usage_tips/mapping_rules.md#platform-mappings)) ### Interface Sync @@ -54,6 +65,8 @@ Create cable connection in NetBox from LibreNMS links data. Create IP address in NetBox from LibreNMS device IP data. +An opt-in **Set Primary IP** toggle on the IP Address Sync tab can also set the device or VM Primary IP. + ### VLAN Sync - Create VLAN objects in NetBox from LibreNMS device VLAN data - Per-VLAN group assignment with scope-aware auto-selection @@ -71,6 +84,12 @@ The plugin also supports synchronizing NetBox Sites with LibreNMS locations: * Update existing LibreNMS locations latitude and longitude values based on NetBox data ⚠️ *(currently not working due to LibreNMS API issue)* * Sync device site to LibreNMS location +### Multi LibreNMS Server Configuration + +* Configure multiple LibreNMS instances in your NetBox configuration +* Switch between different LibreNMS servers through the web interface +* Maintain backward compatibility with single-server configurations + ### Screenshots/GIFs > Screenshots from older plugin version diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4e4a64952..8f1a38b80 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -17,12 +17,15 @@ * [Sync & Configuration](usage_tips/virtual_chassis.md) * [Virtual Chassis](usage_tips/virtual_chassis.md) * [Interface Mappings](usage_tips/interface_mappings.md) + * [Module Sync](usage_tips/module_sync.md) + * [Mapping Rules](usage_tips/mapping_rules.md) * [Development](development/README.md) * [Overview](development/README.md) * [Project Structure](development/structure.md) * [Views & Inheritance](development/views.md) * [Mixins](development/mixins.md) * [Templates](development/templates.md) + * [Extension Points](development/extension_points.md) * [Testing](development/testing.md) * [Changelog](changelog.md) * [Contributing](contributing.md) diff --git a/docs/changelog.md b/docs/changelog.md index adea59d90..612fd0b85 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,30 @@ # Changelog +## 0.4.7 (2026-06-05) + +### New Features +* **Module / Inventory Sync**: New Modules tab on Device and Virtual Chassis pages that reconciles LibreNMS ENTITY-MIB physical inventory (line-cards, transceivers, fans, PSUs) against NetBox module bays and installed modules, with install/update actions directly from the sync table (#261) +* **Mapping Rules**: Configurable mapping system backing module sync β€” `DeviceTypeMapping`, `ModuleTypeMapping`, `ModuleBayMapping` (with regex and manufacturer scoping), `NormalizationRule`, `InventoryIgnoreRule`, and `CarrierAutoInstallRule`, all with bulk YAML import/export (#261) +* **Module Interface Matching**: Follow-up sync logic that matches LibreNMS `port_id` to interface `librenms_id`, installs modules with interface templates (e.g. SFPs), and adopts existing standalone interfaces into installed modules (#296) +* **Set Primary IP**: Opt-in toggle on the IP Address Sync tab that sets the device/VM `primary_ip4`/`primary_ip6` from the LibreNMS management IP when the synced IP is assigned to an interface (#303) +* **Module Interface Name Prediction Hook**: New public `predict_module_interface_names` signal so other plugins can rewrite predicted module interface names, plus a copy-to-issue Virtual Chassis normalization report for unsupported naming conventions (#298) + +### Fixes +* Generate a `slug` when creating a Platform via the device sync page "Create & Sync" button (#282, closes #279) +* Guard `VirtualMachine.cluster` access so the import page no longer crashes for VMs without a cluster (#287, closes #284) +* Fix the Create Platform modal rendering raw template syntax on Django 6 and scope the modal block to devices only (#294) +* Stop rendering a "No matching bay" warning on informational Integrated module rows (#300) +* Fix device Location showing as a raw dict instead of the location name on the sync page (#301, closes #280) + +### Improvements +* Security: restrict `GITHUB_TOKEN` to least-privilege `contents: read` in GitHub Actions workflows (#304) +* Security: localise the open-redirect guard to the redirect sink in `views/mixins.py` (#305) +* Security: avoid exposing exception details in IP address sync responses (#306) + +### Documentation +* Add Module Sync and Mapping Rules guides, a developer Extension Points page, and Set Primary IP coverage; wire the new pages into navigation and re-sync the root and docs READMEs (#288, #307) +* Update Copilot instruction files for accuracy after v0.4.4–v0.4.6 changes and add a release workflow instruction file (#278) + ## 0.4.6 (2026-04-20) ### Fixes diff --git a/docs/development/README.md b/docs/development/README.md index de81c7d02..3a5ffd413 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -8,3 +8,4 @@ This guide is intended for developers and contributors working on the NetBox Lib - [Views & Inheritance](./views.md): How views are organized, inheritance patterns, and extension tips. - [Mixins](./mixins.md): Reusable logic for views, including API access and caching. - [Templates](./templates.md): Template structure, conventions, and customization tips. +- [Extension Points](./extension_points.md): Public signals and hooks other plugins can use to extend behaviour. diff --git a/docs/development/extension_points.md b/docs/development/extension_points.md new file mode 100644 index 000000000..9db072548 --- /dev/null +++ b/docs/development/extension_points.md @@ -0,0 +1,30 @@ +# Extension Points + +The plugin exposes a small number of public hooks so other NetBox plugins can adjust its behaviour without forking the code. These are intentionally stable β€” internal helpers may change between releases, but the hooks described here are meant to be relied on. + +## Module Interface Name Prediction + +When Module Sync adopts a module, it derives the interface names that module *should* have by instantiating the module type's interface templates against the device. Some vendors use naming conventions this can't capture (for example, breakout ports that expand one physical port into several logical interfaces). The `predict_module_interface_names` signal lets another plugin rewrite those names before they are used for matching. + +The signal is defined in [signals.py](../../netbox_librenms_plugin/signals.py) and fired from `get_module_template_interface_names()` in [utils.py](../../netbox_librenms_plugin/utils.py). Each receiver is called with keyword arguments `sender` (the `Module` class), `device`, `module`, and `names` (the predicted `list[str]`, already rewritten for the VC member position where applicable). Always accept `**kwargs` so future arguments don't break your receiver. + +Return the list of names to use, or `None` to leave the current list unchanged (an empty list is valid and means "no interface names"). When multiple receivers are connected they run in connection order and the **last non-`None` return wins**. The signal is dispatched with Django's `send_robust()`, so a receiver that raises is logged and skipped rather than breaking module adoption. + +```python +from django.dispatch import receiver + +from netbox_librenms_plugin.signals import predict_module_interface_names + + +@receiver(predict_module_interface_names) +def expand_breakout_ports(sender, device, module, names, **kwargs): + """Expand each predicted name into four breakout sub-interfaces.""" + expanded = [] + for name in names: + expanded.extend(f"{name}/{lane}" for lane in range(1, 5)) + return expanded +``` + +Connect the receiver from your plugin's `ready()` method so it is registered when NetBox starts. + +When a module on a Virtual Chassis member has no template names matching the member-position pattern, the Module Sync table offers a pre-filled report (built by `build_vc_normalization_report()` in [utils.py](../../netbox_librenms_plugin/utils.py)) that a user can copy into a GitHub issue to request support for that naming convention. This signal is the recommended way to add such support yourself without waiting for an upstream change. diff --git a/docs/development/testing.md b/docs/development/testing.md index 1e1290a25..d680b030a 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -65,6 +65,8 @@ The test suite covers all major plugin functionality. Tests are organized by the | [test_integration_sync.py](../../netbox_librenms_plugin/tests/test_integration_sync.py) | Integration testsβ€”API client against local mock HTTP server | | [test_integration_virtual_chassis.py](../../netbox_librenms_plugin/tests/test_integration_virtual_chassis.py) | Integration testsβ€”VC detection, negative cache, multi-server cache isolation | | [test_view_wiring.py](../../netbox_librenms_plugin/tests/test_view_wiring.py) | Smoke testsβ€”view class MRO, mixin wiring, permission contracts, and template syntax | +| [test_platform_mapping.py](../../netbox_librenms_plugin/tests/test_platform_mapping.py) | PlatformMapping modelβ€”clean validation, YAML serialization, table/form/filterset, and find_matching_platform integration | +| [test_module_replace.py](../../netbox_librenms_plugin/tests/test_module_replace.py) | Module replacementβ€”module swapping, bay reindexing, and replacement validation | Supporting files: diff --git a/docs/feature_list.md b/docs/feature_list.md index 0abaad7b8..c46a70702 100644 --- a/docs/feature_list.md +++ b/docs/feature_list.md @@ -3,12 +3,33 @@ * Search and discover devices from LibreNMS using flexible filters * Validate device prerequisites before import (Site, Device Type, Device Role) * Import devices as physical Devices or Virtual Machines -* Smart matching for Sites, Device Types, and Platforms +* Smart matching for Sites, Device Types, and Platforms (via [mapping rules](usage_tips/mapping_rules.md)) +* Unified Platform creation modal β€” same experience on import page and device sync page * Bulk import support * Automatic Virtual Chassis creation for stackable devices * Background job processing for large device sets * Duplicate detection to prevent re-importing existing devices +### [Module / Inventory Sync](usage_tips/module_sync.md) + +* Compare LibreNMS ENTITY-MIB inventory to NetBox module bays and installed modules +* Install, update, or skip modules directly from the sync table +* Match statuses: Matched, No Bay, No Type, Name Conflict, Not Installed +* Inline modal to create missing ModuleBayTemplate, ModuleTypeMapping, or ModuleBayMapping without leaving the page +* Carrier Auto-Install suggestion for chassis that omit holder modules from SNMP + +### [Mapping Rules](usage_tips/mapping_rules.md) + +* **Platform Mappings** β€” LibreNMS OS string to NetBox Platform +* **Device Type Mappings** β€” LibreNMS hardware string to NetBox DeviceType +* **Module Type Mappings** β€” LibreNMS entPhysicalModelName to NetBox ModuleType (with manufacturer scoping) +* **Module Bay Mappings** β€” LibreNMS entPhysicalName to NetBox bay name (exact or regex, manufacturer scoping) +* **Normalization Rules** β€” regex-based string transformation before matching (strips vendor suffixes etc.) +* **Inventory Ignore Rules** β€” skip or make-transparent phantom EEPROM/IDPROM entities +* **Carrier Auto-Install Rules** β€” suggest carrier module installation for vendors that omit them from SNMP +* Bulk YAML import/export for all mapping types +* Vendor-contributed example rules in `contrib/` + ### Plugin Settings * Multi-server LibreNMS configuration support @@ -52,6 +73,7 @@ ### IP Address Sync {#ip-address-sync} * Create IP address objects in Netbox from LibreNMS device IP data +* Optionally set the device or VM Primary IP from the LibreNMS management IP (opt-in **Set Primary IP** toggle on the IP Address Sync tab) * Best results when the [custom field](usage_tips/custom_field.md) `librenms_id` is populated on interfaces ### VLAN Sync {#vlan-sync} diff --git a/docs/img/Netbox-librenms-plugin-device-sync-fields.png b/docs/img/Netbox-librenms-plugin-device-sync-fields.png new file mode 100644 index 000000000..c30281afe Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-device-sync-fields.png differ diff --git a/docs/img/Netbox-librenms-plugin-import-page.png b/docs/img/Netbox-librenms-plugin-import-page.png new file mode 100644 index 000000000..40822c8c5 Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-import-page.png differ diff --git a/docs/img/Netbox-librenms-plugin-module-sync-tab.png b/docs/img/Netbox-librenms-plugin-module-sync-tab.png new file mode 100644 index 000000000..e76058a18 Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-module-sync-tab.png differ diff --git a/docs/img/carrier_auto_install_rules/list.png b/docs/img/carrier_auto_install_rules/list.png new file mode 100644 index 000000000..939b364d9 Binary files /dev/null and b/docs/img/carrier_auto_install_rules/list.png differ diff --git a/docs/img/device_type_mappings/list.png b/docs/img/device_type_mappings/list.png new file mode 100644 index 000000000..6daa36890 Binary files /dev/null and b/docs/img/device_type_mappings/list.png differ diff --git a/docs/img/inventory_ignore_rules/list.png b/docs/img/inventory_ignore_rules/list.png new file mode 100644 index 000000000..1d959be03 Binary files /dev/null and b/docs/img/inventory_ignore_rules/list.png differ diff --git a/docs/img/module_bay_mappings/list.png b/docs/img/module_bay_mappings/list.png new file mode 100644 index 000000000..ac611cc84 Binary files /dev/null and b/docs/img/module_bay_mappings/list.png differ diff --git a/docs/img/module_type_mappings/add.png b/docs/img/module_type_mappings/add.png new file mode 100644 index 000000000..a2a98698a Binary files /dev/null and b/docs/img/module_type_mappings/add.png differ diff --git a/docs/img/module_type_mappings/list.png b/docs/img/module_type_mappings/list.png new file mode 100644 index 000000000..0fb061a42 Binary files /dev/null and b/docs/img/module_type_mappings/list.png differ diff --git a/docs/img/normalization_rules/add.png b/docs/img/normalization_rules/add.png new file mode 100644 index 000000000..48167aa40 Binary files /dev/null and b/docs/img/normalization_rules/add.png differ diff --git a/docs/img/normalization_rules/list.png b/docs/img/normalization_rules/list.png new file mode 100644 index 000000000..1e9ae0a80 Binary files /dev/null and b/docs/img/normalization_rules/list.png differ diff --git a/docs/img/platform_mappings/add.png b/docs/img/platform_mappings/add.png new file mode 100644 index 000000000..bb8140559 Binary files /dev/null and b/docs/img/platform_mappings/add.png differ diff --git a/docs/img/platform_mappings/list.png b/docs/img/platform_mappings/list.png new file mode 100644 index 000000000..21d59aa18 Binary files /dev/null and b/docs/img/platform_mappings/list.png differ diff --git a/docs/librenms_import/validation.md b/docs/librenms_import/validation.md index efd08257f..4dd9158aa 100644 --- a/docs/librenms_import/validation.md +++ b/docs/librenms_import/validation.md @@ -22,15 +22,15 @@ Click the validation details button to review what's missing and select values f ### Import as Device - **Site** (required) - Auto-matched from LibreNMS location -- **Device Type** (required) - Auto-matched from LibreNMS hardware string +- **Device Type** (required) - Auto-matched from LibreNMS hardware string, or via [Device Type Mapping](../usage_tips/mapping_rules.md#device-type-mappings) - **Device Role** (required) - Must be selected manually -- **Platform** (optional) - Auto-matched from LibreNMS OS +- **Platform** (optional) - Auto-matched from LibreNMS OS via [Platform Mapping](../usage_tips/mapping_rules.md#platform-mappings). If no mapping exists and the platform is not found, a **Create Platform** button opens a modal to create a new NetBox Platform and mapping in one step. - **Rack** (optional) - Available if Site has racks ### Import as Virtual Machine - **Cluster** (required) - Must be selected manually -- **Platform** (optional) - Auto-matched from LibreNMS OS +- **Platform** (optional) - Auto-matched from LibreNMS OS via [Platform Mapping](../usage_tips/mapping_rules.md#platform-mappings). The same **Create Platform** modal is available if needed. ## Virtual Chassis Detection diff --git a/docs/usage_tips/README.md b/docs/usage_tips/README.md index 43527cf3f..ffb3dcc27 100644 --- a/docs/usage_tips/README.md +++ b/docs/usage_tips/README.md @@ -11,11 +11,21 @@ - Create specific mappings for your network equipment types - Pay attention to speed-based mappings for accurate interface types -3. [Multi Server Configuration](multi_server_configuration.md) +3. [Configure Platform Mappings](mapping_rules.md#platform-mappings) (optional) + - Map LibreNMS OS strings to NetBox Platform objects + - Ensures correct platform assignment during device import and sync + +4. [Multi-Server Configuration](multi_server_configuration.md) - Configure multiple LibreNMS instances in your NetBox configuration - Switch between different LibreNMS servers through the web interface - Maintain backward compatibility with single-server configurations +## Module Sync + +[Module Sync Guide](module_sync.md) - Synchronize physical inventory from LibreNMS to NetBox modules + +[Mapping Rules Guide](mapping_rules.md) - Configure all mapping types (Platform, Device Type, Module Type, Module Bay, Normalization, Ignore, Carrier) + ## Device Import [Device Import Guide](../librenms_import/overview.md) - Import devices from LibreNMS into NetBox diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index 2571d1f14..0295a1e05 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -21,9 +21,9 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID ## Manual Custom Field Setup !!! note - On 0.4.4+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.2 releases used an Integer field β€” do not use Integer for new entries. + On 0.4.4+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.4 releases used an Integer field β€” do not use Integer for new entries. -Follow these steps to create the `librenms_id` custom field in NetBox: +If the field was not created automatically (fallback): follow these steps to create the `librenms_id` custom field in NetBox: 1. **Navigate to Custom Fields:** diff --git a/docs/usage_tips/mapping_rules.md b/docs/usage_tips/mapping_rules.md new file mode 100644 index 000000000..2079089f3 --- /dev/null +++ b/docs/usage_tips/mapping_rules.md @@ -0,0 +1,229 @@ +# Mapping Rules + +Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet. + +All mapping types live under **Plugins > LibreNMS > Mappings** in the NetBox menu and support individual creation, editing, deletion, and bulk YAML import/export. + +--- + +## Platform Mappings + +Maps a LibreNMS OS string (e.g. `junos`, `eos`, `ios`) to a NetBox Platform object. + +**Used by:** +- Device Field Sync β€” platform sync on the device LibreNMS-Sync page +- Device Import β€” platform auto-match when importing devices from LibreNMS + +Matching is case-insensitive. The plugin first tries an exact `Platform.name` match against existing NetBox Platforms; if none (or ambiguous), it falls back to `PlatformMapping`. If neither produces a unique result, the field is left empty. + +**YAML format:** + +```yaml +- librenms_os: junos + netbox_platform: JunOS + description: "Juniper JunOS" +``` + +**Screenshot:** + +![Platform Mapping List](../img/platform_mappings/list.png) + +--- + +## Device Type Mappings + +Maps a LibreNMS hardware string (e.g. `Juniper MX480 Internet Backbone Router`) to a NetBox DeviceType object. + +**Used by:** +- Device Import β€” device type auto-match when importing + +Matching is case-insensitive and exact: the LibreNMS hardware string must equal the DeviceTypeMapping's `librenms_hardware` value (or, if no mapping matches, the DeviceType's `part_number` or `model`). The plugin does not perform partial or containment matching. + +**YAML format:** + +```yaml +- librenms_hardware: "Juniper MX480 Internet Backbone Router" + netbox_device_type: MX480 + description: "Juniper MX480" +``` + +**Screenshot:** + +![Device Type Mapping List](../img/device_type_mappings/list.png) + +--- + +## Module Type Mappings + +Maps a LibreNMS `entPhysicalModelName` string (e.g. `SFP-1G-T`, `3HE16474AA`) to a NetBox ModuleType object. + +**Used by:** +- Module Sync β€” determines which NetBox ModuleType to install or match + +Optionally scoped to a **Manufacturer**: when both a manufacturer-scoped and a global mapping exist for the same model string, the manufacturer-scoped row wins for devices from that vendor. + +**YAML format:** + +```yaml +- librenms_model: SFP-1G-T + manufacturer: "" + netbox_module_type: SFP-1G-T + description: "1G copper SFP" + +- librenms_model: 3HE16474AA + manufacturer: Nokia + netbox_module_type: "3HE16474AA" + description: "Nokia CPM" +``` + +**Screenshot:** + +![Module Type Mapping List](../img/module_type_mappings/list.png) + +--- + +## Module Bay Mappings + +Maps a LibreNMS `entPhysicalName` string (e.g. `Power Supply 1`) to a NetBox module bay name (e.g. `PSU1`). + +**Used by:** +- Module Sync β€” resolves which NetBox module bay a LibreNMS inventory row corresponds to + +Supports: +- **Exact match** β€” simple string substitution +- **Regex match** β€” `librenms_name` is a Python regex; `netbox_bay_name` can use backreferences (`\1`, `\2`, …) +- **Class filter** β€” optional `librenms_class` field (e.g. `powerSupply`, `fan`) to restrict the mapping to items of that ENTITY-MIB class +- **Manufacturer scoping** β€” vendor-scoped mapping wins over a global one when both match + +**YAML format:** + +```yaml +- librenms_name: "Power Supply 1" + librenms_class: powerSupply + netbox_bay_name: PSU1 + is_regex: false + manufacturer: "" + description: "" + +- librenms_name: "^FPC(\\d+)$" + librenms_class: module + netbox_bay_name: "FPC\\1" + is_regex: true + manufacturer: Juniper + description: "FPC slot regex" +``` + +**Screenshot:** + +![Module Bay Mapping List](../img/module_bay_mappings/list.png) + +--- + +## Normalization Rules + +Regex-based string transformations applied *before* ModuleTypeMapping or ModuleBayMapping lookups. Rules are chained in priority order (lower number = runs first); each rule transforms the output of the previous. + +**Scopes:** +- `module_type` β€” normalizes `entPhysicalModelName` before ModuleTypeMapping lookup +- `device_type` β€” normalizes LibreNMS hardware string before DeviceTypeMapping lookup +- `module_bay` β€” normalizes `entPhysicalName` before ModuleBayMapping lookup + +Useful for stripping vendor revision suffixes so a single ModuleTypeMapping entry covers all hardware revisions. + +**Example:** strip Nokia revision suffixes + +```text +scope: module_type +manufacturer: Nokia +match_pattern: ^(3HE\w{5}[A-Z]{2})[A-Z]{2}\d{2}$ +replacement: \1 +Result: 3HE16474AARA01 -> 3HE16474AA +``` + +**YAML format:** + +```yaml +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$" + replacement: "\\1" + priority: 10 + description: "Strip Nokia revision suffix" +``` + +**Screenshot:** + +![Normalization Rule List](../img/normalization_rules/list.png) + +--- + +## Inventory Ignore Rules + +Filter or reclassify specific ENTITY-MIB inventory items that would otherwise appear as spurious rows in the Module Sync table. + +**Actions:** +- **Skip** β€” removes the matched item from the sync table entirely. Use for phantom EEPROM/IDPROM child entities that some vendors (Cisco IOS-XR) report with the same model and serial as the parent. +- **Transparent** β€” hides the row but *promotes* its children to device-level bay matching. Use for fixed-chassis devices (e.g. Cisco 8201-SYS) where the system board entity is the device itself and its children (transceivers, fans, PSUs) should be matched directly against device-level bays. + +**Match types:** +- `ends_with` / `starts_with` / `contains` β€” compare `entPhysicalName` (case-insensitive) +- `regex` β€” Python regex against `entPhysicalName` +- `serial_matches_device` β€” matches when `entPhysicalSerialNum` equals the NetBox device's own serial (no pattern required) + +The **Require serial match parent** option (recommended) adds a safety net: the name-based rule only fires when the item's serial number also matches an ancestor entity's serial. + +**YAML format:** + +```yaml +- name: "Cisco IOS-XR IDPROM phantom" + match_type: ends_with + pattern: "IDPROM" + action: skip + require_serial_match_parent: true + enabled: true + description: "Remove IDPROM phantoms from Cisco IOS-XR inventory" +``` + +**Screenshot:** + +![Inventory Ignore Rule List](../img/inventory_ignore_rules/list.png) + +--- + +## Carrier Auto-Install Rules + +Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have a bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay." + +Rules are **suggest-only** β€” no module is installed automatically. The user clicks an **Install Carrier** button that appears in the Module Sync table when a rule fires. + +**Fields:** +- `manufacturer` β€” optional; scope to one vendor +- `device_type_pattern` β€” optional regex (fullmatch) on DeviceType model name +- `librenms_child_class` β€” exact `entPhysicalClass` of the orphan (e.g. `cpmModule`) +- `librenms_child_name_pattern` β€” regex (fullmatch) on the orphan's `entPhysicalName` +- `netbox_bay_name_pattern` β€” regex (fullmatch) on empty chassis-level bay name(s) to offer as install targets +- `carrier_module_type` β€” the ModuleType to suggest installing + +**YAML format:** + +```yaml +- manufacturer: Nokia + device_type_pattern: ".*SR-s.*" + librenms_child_class: cpmModule + librenms_child_name_pattern: "^Slot [AB]$" + netbox_bay_name_pattern: "^CMA$" + carrier_module_type: "Nokia CMA Carrier" + description: "Nokia 7750 SR-s CMA carrier suggestion" +``` + +**Screenshot:** + +![Carrier Auto-Install Rule List](../img/carrier_auto_install_rules/list.png) + +--- + +## Bulk Import / Export + +All mapping types support NetBox's standard bulk YAML import. Click the **Import** button next to any mapping list. You can also export existing mappings as YAML for backup or cross-environment migration using the **Export** action on the list page. + +Pre-built example rules for common vendor patterns are available in [`contrib/`](../../contrib/) in the plugin repository. Pull requests with additional examples or corrections are welcome. diff --git a/docs/usage_tips/module_sync.md b/docs/usage_tips/module_sync.md new file mode 100644 index 000000000..656714325 --- /dev/null +++ b/docs/usage_tips/module_sync.md @@ -0,0 +1,62 @@ +# Module Sync + +The Module Sync tab lets you reconcile LibreNMS physical inventory (ENTITY-MIB, plus a transceiver API source for vendors that don't expose SFPs via ENTITY-MIB) with the installed modules recorded in NetBox. It appears as a **Modules** tab on the LibreNMS Sync page for every Device and Virtual Chassis. + +## How It Works + +When you open the Modules tab, the plugin fetches the ENTITY-MIB inventory tree from LibreNMS, merges in transceiver data from the LibreNMS transceiver API (used for vendors that don't expose SFPs via ENTITY-MIB), and compares each line-card, transceiver, fan, power supply, and other physical component against the NetBox module bays and installed modules for that device. + +Each row in the table shows: + +- **LibreNMS Name** β€” the `entPhysicalName` value reported by the device +- **LibreNMS Model** β€” the `entPhysicalModelName` value (used for type matching) +- **Serial** β€” `entPhysicalSerialNum` +- **Status** β€” the result of matching (see below) +- **NetBox Bay** β€” the module bay the component was matched to +- **NetBox Module** β€” the installed module in that bay (if any) +- **Actions** β€” buttons to install, update, or map the component + +## Match Statuses + +| Status | Meaning | +|--------|---------| +| **Matched** | LibreNMS component matches an installed NetBox module (same bay, same or mapped type) | +| **No Bay** | A bay mapping was found but no matching module bay exists on this device | +| **No Type** | The bay was found but no ModuleTypeMapping exists for the LibreNMS model | +| **Name Conflict** | The resolved bay name conflicts with an existing module in another bay | +| **Not Installed** | A matching bay exists but no module is installed yet | + +## Taking Action + +- **Install** β€” installs a new module into the matched bay using the mapped ModuleType +- **Update** β€” updates the serial number or module type of an existing installed module +- **Add Mapping** β€” opens the ModuleBayMapping or ModuleTypeMapping creation modal directly from the row, so you can resolve "No Bay" or "No Type" statuses without leaving the page +- **Add Bay Template** β€” if the device type is missing a module bay template, this button creates it inline + +## Module Interfaces + +When a module type defines interface templates, installing the module also reconciles the device's interfaces with those templates. The plugin works out the names the module's interfaces should have (instantiating each interface template against the device, and rewriting names for Virtual Chassis members where needed), then adopts any existing standalone interfaces on the device that match those names into the newly installed module. This keeps interfaces that were created by a previous interface sync from being duplicated, and links them to the module that physically provides them. + +For vendors whose naming conventions cannot be derived from templates alone, the predicted names can be adjusted by another plugin through a signal hook β€” see [Extension Points](../development/extension_points.md) for details. + +## Carrier Modules + +Some chassis (e.g. Nokia 7750 SR-s) report child components (CPMs, MDAs) without the intermediate carrier/holder module that must first be installed in NetBox before the children become visible. When a CarrierAutoInstallRule matches, an **Install Carrier** button appears β€” clicking it installs the carrier module into the appropriate empty bay, after which the children can be synced normally. + +## Virtual Chassis Support + +For Virtual Chassis devices, the Modules tab automatically distributes inventory rows across the correct VC member based on the component's position in the ENTITY-MIB tree. Each row shows the member hostname to make it clear which physical switch a component belongs to. + +## Screenshot + +![Module Sync Tab](../img/Netbox-librenms-plugin-module-sync-tab.png) + +## Related Configuration + +Before using Module Sync you will typically need to configure one or more of the following mapping types (see [Mapping Rules](mapping_rules.md)): + +- **ModuleTypeMapping** β€” maps LibreNMS model strings (e.g. `SFP-1G-T`) to NetBox ModuleType objects +- **ModuleBayMapping** β€” maps LibreNMS bay names (e.g. `Power Supply 1`) to NetBox bay names (e.g. `PSU1`), with optional regex and manufacturer scoping +- **NormalizationRule** β€” strips vendor suffixes from model strings before matching (e.g. `3HE16474AARA01` β†’ `3HE16474AA`) +- **InventoryIgnoreRule** β€” skips or makes transparent phantom EEPROM/IDPROM entities that some vendors (Cisco IOS-XR) report +- **CarrierAutoInstallRule** β€” suggests installing carrier modules for vendors that omit them from SNMP reporting diff --git a/mkdocs.yml b/mkdocs.yml index 2fd2259ae..e38dd729a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,12 +21,15 @@ nav: - Sync & Configuration: - Virtual Chassis: usage_tips/virtual_chassis.md - Interface Mappings: usage_tips/interface_mappings.md + - Module Sync: usage_tips/module_sync.md + - Mapping Rules: usage_tips/mapping_rules.md - Development: - Overview: development/README.md - Project Structure: development/structure.md - Views & Inheritance: development/views.md - Mixins: development/mixins.md - Templates: development/templates.md + - Extension Points: development/extension_points.md - Testing: development/testing.md - Changelog: changelog.md - Contributing: contributing.md diff --git a/netbox_librenms_plugin/__init__.py b/netbox_librenms_plugin/__init__.py index 9beae9fd8..8709db441 100644 --- a/netbox_librenms_plugin/__init__.py +++ b/netbox_librenms_plugin/__init__.py @@ -2,7 +2,7 @@ from netbox.plugins import PluginConfig __author__ = "Andy Norwood" -__version__ = "0.4.6" +__version__ = "0.4.7" class LibreNMSSyncConfig(PluginConfig): diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef2..fe576d712 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -1,6 +1,15 @@ from netbox.api.serializers import NetBoxModelSerializer -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + CarrierAutoInstallRule, + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) class InterfaceTypeMappingSerializer(NetBoxModelSerializer): @@ -11,3 +20,112 @@ class Meta: model = InterfaceTypeMapping fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingSerializer(NetBoxModelSerializer): + """Serialize DeviceTypeMapping model for REST API.""" + + class Meta: + """Meta options for DeviceTypeMappingSerializer.""" + + model = DeviceTypeMapping + fields = ["id", "librenms_hardware", "netbox_device_type", "description"] + + +class ModuleTypeMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleTypeMapping model for REST API.""" + + class Meta: + """Meta options for ModuleTypeMappingSerializer.""" + + model = ModuleTypeMapping + fields = ["id", "librenms_model", "manufacturer", "netbox_module_type", "description"] + + +class ModuleBayMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleBayMapping model for REST API.""" + + class Meta: + """Meta options for ModuleBayMappingSerializer.""" + + model = ModuleBayMapping + fields = [ + "id", + "librenms_name", + "librenms_class", + "netbox_bay_name", + "is_regex", + "manufacturer", + "description", + ] + + +class NormalizationRuleSerializer(NetBoxModelSerializer): + """Serialize NormalizationRule model for REST API.""" + + class Meta: + """Meta options for NormalizationRuleSerializer.""" + + model = NormalizationRule + fields = [ + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "description", + ] + + +class InventoryIgnoreRuleSerializer(NetBoxModelSerializer): + """Serialize InventoryIgnoreRule model for REST API.""" + + class Meta: + """Meta options for InventoryIgnoreRuleSerializer.""" + + model = InventoryIgnoreRule + fields = [ + "id", + "name", + "match_type", + "pattern", + "action", + "require_serial_match_parent", + "enabled", + "description", + ] + + +class PlatformMappingSerializer(NetBoxModelSerializer): + """Serialize PlatformMapping model for REST API.""" + + class Meta: + """Meta options for PlatformMappingSerializer.""" + + model = PlatformMapping + fields = [ + "id", + "librenms_os", + "netbox_platform", + "description", + ] + + +class CarrierAutoInstallRuleSerializer(NetBoxModelSerializer): + """Serialize CarrierAutoInstallRule model for REST API.""" + + class Meta: + """Meta options for CarrierAutoInstallRuleSerializer.""" + + model = CarrierAutoInstallRule + fields = [ + "id", + "manufacturer", + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + "carrier_module_type", + "description", + ] diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index 230aa078d..de5f82b4a 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -7,6 +7,13 @@ router = NetBoxRouter() router.register("interface-type-mappings", views.InterfaceTypeMappingViewSet) +router.register("device-type-mappings", views.DeviceTypeMappingViewSet) +router.register("module-type-mappings", views.ModuleTypeMappingViewSet) +router.register("module-bay-mappings", views.ModuleBayMappingViewSet) +router.register("normalization-rules", views.NormalizationRuleViewSet) +router.register("inventory-ignore-rules", views.InventoryIgnoreRuleViewSet) +router.register("platform-mappings", views.PlatformMappingViewSet) +router.register("carrier-auto-install-rules", views.CarrierAutoInstallRuleViewSet) urlpatterns = [ path("jobs//sync-status/", views.sync_job_status, name="sync_job_status"), diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index a5d440b8f..a73fb8069 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -12,14 +12,43 @@ from rq.job import Job as RQJob from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN -from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet +from netbox_librenms_plugin.filters import ( + CarrierAutoInstallRuleFilterSet, + DeviceTypeMappingFilterSet, + InterfaceTypeMappingFilterSet, + InventoryIgnoreRuleFilterSet, + ModuleBayMappingFilterSet, + ModuleTypeMappingFilterSet, + NormalizationRuleFilterSet, + PlatformMappingFilterSet, +) from netbox_librenms_plugin.jobs import FilterDevicesJob, ImportDevicesJob -from netbox_librenms_plugin.models import InterfaceTypeMapping - -from .serializers import InterfaceTypeMappingSerializer +from netbox_librenms_plugin.models import ( + CarrierAutoInstallRule, + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) + +from .serializers import ( + CarrierAutoInstallRuleSerializer, + DeviceTypeMappingSerializer, + InterfaceTypeMappingSerializer, + InventoryIgnoreRuleSerializer, + ModuleBayMappingSerializer, + ModuleTypeMappingSerializer, + NormalizationRuleSerializer, + PlatformMappingSerializer, +) logger = logging.getLogger(__name__) +_LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name) + class LibreNMSPluginPermission(BasePermission): """ @@ -45,6 +74,76 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet): serializer_class = InterfaceTypeMappingSerializer +class DeviceTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for DeviceTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = DeviceTypeMappingFilterSet + + queryset = DeviceTypeMapping.objects.select_related("netbox_device_type") + serializer_class = DeviceTypeMappingSerializer + + +class ModuleTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = ModuleTypeMappingFilterSet + + queryset = ModuleTypeMapping.objects.select_related("netbox_module_type", "manufacturer") + serializer_class = ModuleTypeMappingSerializer + + +class ModuleBayMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleBayMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = ModuleBayMappingFilterSet + + queryset = ModuleBayMapping.objects.select_related("manufacturer") + serializer_class = ModuleBayMappingSerializer + + +class NormalizationRuleViewSet(NetBoxModelViewSet): + """API viewset for NormalizationRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = NormalizationRuleFilterSet + + queryset = NormalizationRule.objects.select_related("manufacturer") + serializer_class = NormalizationRuleSerializer + + +class InventoryIgnoreRuleViewSet(NetBoxModelViewSet): + """API viewset for InventoryIgnoreRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = InventoryIgnoreRuleFilterSet + + queryset = InventoryIgnoreRule.objects.all() + serializer_class = InventoryIgnoreRuleSerializer + + +class PlatformMappingViewSet(NetBoxModelViewSet): + """API viewset for PlatformMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = PlatformMappingFilterSet + + queryset = PlatformMapping.objects.select_related("netbox_platform") + serializer_class = PlatformMappingSerializer + + +class CarrierAutoInstallRuleViewSet(NetBoxModelViewSet): + """API viewset for CarrierAutoInstallRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = CarrierAutoInstallRuleFilterSet + + queryset = CarrierAutoInstallRule.objects.select_related("manufacturer", "carrier_module_type") + serializer_class = CarrierAutoInstallRuleSerializer + + @api_view(["POST"]) @permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): @@ -63,20 +162,26 @@ def sync_job_status(request, job_pk): Returns: JsonResponse with updated status """ - _LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name) try: job = Job.objects.get(pk=job_pk, user=request.user, name__in=_LIBRENMS_JOB_NAMES) except Job.DoesNotExist: return JsonResponse({"error": "Job not found"}, status=404) # Get RQ job status - queue = get_queue("default") try: + queue = get_queue("default") rq_job = RQJob.fetch(str(job.job_id), connection=queue.connection) rq_status = rq_job.get_status() - # If RQ job is stopped or failed, update database + # If RQ job is stopped or failed, update database (but never overwrite terminal states) if rq_job.is_stopped or rq_job.is_failed: + terminal_states = { + JobStatusChoices.STATUS_COMPLETED, + JobStatusChoices.STATUS_FAILED, + JobStatusChoices.STATUS_ERRORED, + } + if job.status in terminal_states: + return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": rq_status}) job.status = JobStatusChoices.STATUS_FAILED if not job.completed: job.completed = timezone.now() diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64..1edb57773 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -1,13 +1,145 @@ import django_filters +from dcim.models import Manufacturer -from .models import InterfaceTypeMapping +from .models import ( + CarrierAutoInstallRule, + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) class InterfaceTypeMappingFilterSet(django_filters.FilterSet): """Filter set for InterfaceTypeMapping model.""" + # Explicit declarations ensure filter names match form field names. + # Dict-style fields = {"field": ["icontains"]} generates librenms_type__icontains, + # but the filter form submits librenms_type β€” causing silent filter failures. + librenms_type = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + class Meta: """Meta options for InterfaceTypeMappingFilterSet.""" model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for DeviceTypeMapping model.""" + + librenms_hardware = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + + class Meta: + """Meta options for DeviceTypeMappingFilterSet.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "description"] + + +class ModuleTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleTypeMapping model.""" + + librenms_model = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + manufacturer_id = django_filters.ModelChoiceFilter( + field_name="manufacturer", + queryset=Manufacturer.objects.all(), + label="Manufacturer", + ) + + class Meta: + """Meta options for ModuleTypeMappingFilterSet.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "description", "manufacturer_id"] + + +class ModuleBayMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleBayMapping model.""" + + librenms_name = django_filters.CharFilter(lookup_expr="icontains") + librenms_class = django_filters.CharFilter(lookup_expr="icontains") + netbox_bay_name = django_filters.CharFilter(lookup_expr="icontains") + manufacturer_id = django_filters.ModelChoiceFilter( + field_name="manufacturer", + queryset=Manufacturer.objects.all(), + label="Manufacturer", + ) + + class Meta: + """Meta options for ModuleBayMappingFilterSet.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer_id"] + + +class NormalizationRuleFilterSet(django_filters.FilterSet): + """Filter set for NormalizationRule model.""" + + # DynamicModelChoiceField submits manufacturer_id; use a ModelChoiceFilter + # with field_name="manufacturer" so the filterset resolves it to the FK. + manufacturer_id = django_filters.ModelChoiceFilter( + field_name="manufacturer", + queryset=Manufacturer.objects.all(), + label="Manufacturer", + ) + + class Meta: + """Meta options for NormalizationRuleFilterSet.""" + + model = NormalizationRule + fields = ["scope", "manufacturer_id"] + + +class InventoryIgnoreRuleFilterSet(django_filters.FilterSet): + """Filter set for InventoryIgnoreRule model.""" + + class Meta: + """Meta options for InventoryIgnoreRuleFilterSet.""" + + model = InventoryIgnoreRule + fields = ["match_type", "action", "enabled"] + + +class PlatformMappingFilterSet(django_filters.FilterSet): + """Filter set for PlatformMapping model.""" + + librenms_os = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + + class Meta: + """Meta options for PlatformMappingFilterSet.""" + + model = PlatformMapping + fields = ["librenms_os", "description"] + + +class CarrierAutoInstallRuleFilterSet(django_filters.FilterSet): + """Filter set for CarrierAutoInstallRule model.""" + + manufacturer_id = django_filters.ModelChoiceFilter( + field_name="manufacturer", + queryset=Manufacturer.objects.all(), + label="Manufacturer", + ) + librenms_child_class = django_filters.CharFilter(lookup_expr="icontains") + librenms_child_name_pattern = django_filters.CharFilter(lookup_expr="icontains") + netbox_bay_name_pattern = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + + class Meta: + """Meta options for CarrierAutoInstallRuleFilterSet.""" + + model = CarrierAutoInstallRule + fields = [ + "manufacturer_id", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + ] diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index da1417b71..1dbc10c87 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -1,8 +1,9 @@ # forms.py import logging +import re from dcim.choices import InterfaceTypeChoices -from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, ModuleType, Platform, Rack, Site from django import forms from django.db.models import Case, IntegerField, Value, When from django.http import QueryDict @@ -13,10 +14,25 @@ NetBoxModelImportForm, ) from netbox.plugins import get_plugin_config -from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + CSVChoiceField, + CSVModelChoiceField, + DynamicModelChoiceField, + DynamicModelMultipleChoiceField, +) from virtualization.models import Cluster, VirtualMachine -from .models import InterfaceTypeMapping, LibreNMSSettings +from .models import ( + CarrierAutoInstallRule, + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + LibreNMSSettings, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) logger = logging.getLogger(__name__) @@ -51,14 +67,27 @@ def _get_librenms_server_choices(): def _get_librenms_poller_group_choices(): """ Helper function to get poller group choices from LibreNMS API. - Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms. + Shared between AddToLibreSNMPV1V2 and AddToLibreSNMPV3 forms (via BaseSNMPForm). + Results are cached to avoid repeated API calls on every form instantiation. """ + from django.core.cache import cache + from .librenms_api import LibreNMSAPI choices = [("0", "Default (0)")] try: api = LibreNMSAPI() + except Exception: + logger.exception("Failed to initialize LibreNMSAPI; using default poller group choices") + return choices + + cache_key = f"librenms_poller_group_choices_{api.server_key}" + cached_choices = cache.get(cache_key) + if cached_choices is not None: + return cached_choices + + try: success, poller_groups = api.get_poller_groups() if success: @@ -73,6 +102,8 @@ def _get_librenms_poller_group_choices(): else: label = f"{group_name} ({group_id})" choices.append((group_id, label)) + + cache.set(cache_key, choices, timeout=api.cache_timeout) except Exception: logger.exception("Failed to fetch LibreNMS poller groups; using default choices") @@ -153,8 +184,6 @@ def clean_vc_member_name_pattern(self): return pattern # Check for valid placeholder names using regex - import re - valid_placeholders = {"position", "serial"} found_placeholders = set(re.findall(r"\{(\w+)\}", pattern)) invalid_placeholders = found_placeholders - valid_placeholders @@ -261,11 +290,424 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm): model = InterfaceTypeMapping -class AddToLIbreSNMPV1V2(forms.Form): +class DeviceTypeMappingForm(NetBoxModelForm): + """Form for creating and editing device type mappings between LibreNMS and NetBox.""" + + netbox_device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + label="NetBox Device Type", + ) + + class Meta: + """Meta options for DeviceTypeMappingForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "netbox_device_type", "description"] + + +class DeviceTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing device type mappings.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Manufacturer name β€” required when the model name is not unique across manufacturers", + ) + netbox_device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name="model", + help_text="NetBox device type model name", + ) + + class Meta: + """Meta options for DeviceTypeMappingImportForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "manufacturer", "netbox_device_type", "description"] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + mfr_val = (data.get("manufacturer") or "").strip() + if mfr_val: + mfr_field = self.fields["manufacturer"] + params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val} + self.fields["netbox_device_type"].queryset = DeviceType.objects.filter(**params) + + +class DeviceTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering device type mappings.""" + + librenms_hardware = forms.CharField(required=False, label="LibreNMS Hardware") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = DeviceTypeMapping + + +class ModuleTypeMappingForm(NetBoxModelForm): + """Form for creating and editing module type mappings between LibreNMS and NetBox.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this mapping to a single manufacturer.", + ) + netbox_module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + label="NetBox Module Type", + query_params={"manufacturer_id": "$manufacturer"}, + ) + + class Meta: + """Meta options for ModuleTypeMappingForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "manufacturer", "netbox_module_type", "description"] + + +class ModuleTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module type mappings.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Manufacturer name β€” required when the model name is not unique across manufacturers", + ) + netbox_module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name="model", + help_text="NetBox module type model name", + ) + + class Meta: + """Meta options for ModuleTypeMappingImportForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "manufacturer", "netbox_module_type", "description"] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + mfr_val = (data.get("manufacturer") or "").strip() + if mfr_val: + mfr_field = self.fields["manufacturer"] + params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val} + self.fields["netbox_module_type"].queryset = ModuleType.objects.filter(**params) + + +class ModuleTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module type mappings.""" + + librenms_model = forms.CharField(required=False, label="LibreNMS Model") + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = ModuleTypeMapping + + +class ModuleBayMappingForm(NetBoxModelForm): + """Form for creating and editing module bay mappings between LibreNMS and NetBox.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this mapping to a single manufacturer.", + ) + + class Meta: + """Meta options for ModuleBayMappingForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer", "description"] + + +class ModuleBayMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module bay mappings.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Optional manufacturer name (leave blank for vendor-agnostic mappings).", + ) + + class Meta: + """Meta options for ModuleBayMappingImportForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer", "description"] + + +class ModuleBayMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module bay mappings.""" + + librenms_name = forms.CharField(required=False, label="LibreNMS Name") + librenms_class = forms.CharField(required=False, label="LibreNMS Class") + netbox_bay_name = forms.CharField(required=False, label="NetBox Bay Name") + is_regex = forms.NullBooleanField( + required=False, + widget=forms.Select(choices=[("", "---------"), ("true", "Yes"), ("false", "No")]), + label="Regex", + ) + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + + model = ModuleBayMapping + + +class CarrierAutoInstallRuleForm(NetBoxModelForm): + """Form for creating and editing carrier auto-install rules.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this rule to a single manufacturer.", + ) + carrier_module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + label="Carrier Module Type", + query_params={"manufacturer_id": "$manufacturer"}, + ) + + class Meta: + """Meta options for CarrierAutoInstallRuleForm.""" + + model = CarrierAutoInstallRule + fields = [ + "manufacturer", + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + "carrier_module_type", + "description", + ] + + +class CarrierAutoInstallRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing carrier auto-install rules.""" + + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Manufacturer name (optional β€” leave blank for vendor-agnostic rules).", + ) + carrier_module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name="model", + help_text="NetBox ModuleType model name to install.", + ) + + class Meta: + """Meta options for CarrierAutoInstallRuleImportForm.""" + + model = CarrierAutoInstallRule + fields = [ + "manufacturer", + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + "carrier_module_type", + "description", + ] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + if data: + mfr_val = (data.get("manufacturer") or "").strip() + if mfr_val: + mfr_field = self.fields["manufacturer"] + params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val} + self.fields["carrier_module_type"].queryset = ModuleType.objects.filter(**params) + + +class CarrierAutoInstallRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering carrier auto-install rules.""" + + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + librenms_child_class = forms.CharField(required=False, label="LibreNMS Child Class") + librenms_child_name_pattern = forms.CharField(required=False, label="LibreNMS Child Name Pattern") + netbox_bay_name_pattern = forms.CharField(required=False, label="NetBox Bay Name Pattern") + description = forms.CharField(required=False, label="Description") + + model = CarrierAutoInstallRule + + +class NormalizationRuleForm(NetBoxModelForm): + """Form for creating and editing normalization rules.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this rule to a specific manufacturer", + ) + + class Meta: + """Meta options for NormalizationRuleForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing normalization rules.""" + + scope = CSVChoiceField( + choices=NormalizationRule.SCOPE_CHOICES, + help_text="Scope: module_type, device_type, or module_bay", + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Optional manufacturer name (must already exist in NetBox)", + ) + + class Meta: + """Meta options for NormalizationRuleImportForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering normalization rules.""" + + scope = forms.ChoiceField( + required=False, + choices=[("", "---------")] + NormalizationRule.SCOPE_CHOICES, + label="Scope", + ) + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + + model = NormalizationRule + + +class InventoryIgnoreRuleForm(NetBoxModelForm): + """Form for creating and editing inventory ignore rules.""" + + class Meta: + """Meta options for InventoryIgnoreRuleForm.""" + + model = InventoryIgnoreRule + fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"] + + +class InventoryIgnoreRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing inventory ignore rules.""" + + match_type = CSVChoiceField( + choices=InventoryIgnoreRule.MATCH_TYPE_CHOICES, + help_text="Match type: ends_with, starts_with, contains, regex, or serial_matches_device", + ) + action = CSVChoiceField( + choices=InventoryIgnoreRule.ACTION_CHOICES, + help_text="Action: skip (remove from table) or transparent (hide row, promote children)", + ) + + class Meta: + """Meta options for InventoryIgnoreRuleImportForm.""" + + model = InventoryIgnoreRule + fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"] + + +class InventoryIgnoreRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering inventory ignore rules.""" + + match_type = forms.ChoiceField( + required=False, + choices=[("", "---------")] + InventoryIgnoreRule.MATCH_TYPE_CHOICES, + label="Match Type", + ) + action = forms.ChoiceField( + required=False, + choices=[("", "---------")] + InventoryIgnoreRule.ACTION_CHOICES, + label="Action", + ) + enabled = forms.NullBooleanField( + required=False, + widget=forms.Select(choices=[("", "---------"), ("true", "Yes"), ("false", "No")]), + label="Enabled", + ) + + model = InventoryIgnoreRule + + +class PlatformMappingForm(NetBoxModelForm): + """Form for creating and editing platform mappings between LibreNMS and NetBox.""" + + netbox_platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + label="NetBox Platform", + ) + + class Meta: + """Meta options for PlatformMappingForm.""" + + model = PlatformMapping + fields = ["librenms_os", "netbox_platform", "description"] + + +class PlatformMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing platform mappings.""" + + netbox_platform = CSVModelChoiceField( + queryset=Platform.objects.all(), + to_field_name="name", + help_text="NetBox platform name", + ) + + class Meta: + """Meta options for PlatformMappingImportForm.""" + + model = PlatformMapping + fields = ["librenms_os", "netbox_platform", "description"] + + +class PlatformMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering platform mappings.""" + + librenms_os = forms.CharField(required=False, label="LibreNMS OS") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = PlatformMapping + + +class BaseSNMPForm(forms.Form): """ - Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. - Collects hostname/IP and SNMP community string information. - The SNMP version (v1 or v2c) is selected via a toggle button in the template. + Base form with fields shared by both SNMPv1/v2c and SNMPv3 LibreNMS device forms. """ hostname = forms.CharField( @@ -273,7 +715,6 @@ class AddToLIbreSNMPV1V2(forms.Form): max_length=255, required=True, ) - community = forms.CharField(label="SNMP Community", max_length=255, required=True) port = forms.IntegerField( label="SNMP Port", required=False, @@ -320,17 +761,31 @@ def __init__(self, *args, **kwargs): self.fields["poller_group"].choices = _get_librenms_poller_group_choices() -class AddToLIbreSNMPV3(forms.Form): +class AddToLibreSNMPV1V2(BaseSNMPForm): """ - Form for adding devices to LibreNMS using SNMPv3 authentication. - Provides comprehensive SNMPv3 configuration options including authentication and encryption settings. + Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. + Collects hostname/IP and SNMP community string information. + The SNMP version (v1 or v2c) is selected via a toggle button in the template. """ - hostname = forms.CharField( - label="Hostname/IP", + community = forms.CharField( + label="SNMP Community", max_length=255, required=True, + widget=forms.PasswordInput(), ) + + +# Backwards-compatible alias β€” remove once all references are updated. +AddToLIbreSNMPV1V2 = AddToLibreSNMPV1V2 + + +class AddToLibreSNMPV3(BaseSNMPForm): + """ + Form for adding devices to LibreNMS using SNMPv3 authentication. + Provides comprehensive SNMPv3 configuration options including authentication and encryption settings. + """ + snmp_version = forms.CharField(widget=forms.HiddenInput(), initial="v3") authlevel = forms.ChoiceField( label="Auth Level", @@ -345,8 +800,8 @@ class AddToLIbreSNMPV3(forms.Form): authpass = forms.CharField( label="Auth Password", max_length=255, - required=True, - widget=forms.PasswordInput(render_value=True), + required=False, + widget=forms.PasswordInput(), ) authalgo = forms.ChoiceField( label="Auth Algorithm", @@ -358,63 +813,41 @@ class AddToLIbreSNMPV3(forms.Form): ("SHA-384", "SHA-384"), ("SHA-512", "SHA-512"), ], - required=True, + required=False, ) cryptopass = forms.CharField( label="Crypto Password", max_length=255, - required=True, - widget=forms.PasswordInput(render_value=True), + required=False, + widget=forms.PasswordInput(), ) cryptoalgo = forms.ChoiceField( label="Crypto Algorithm", choices=[("AES", "AES"), ("DES", "DES")], - required=True, - ) - port = forms.IntegerField( - label="SNMP Port", - required=False, - help_text="Leave blank to use default SNMP port (161)", - widget=forms.NumberInput(attrs={"placeholder": "161"}), - ) - transport = forms.ChoiceField( - label="Transport", - choices=[ - ("udp", "UDP"), - ("tcp", "TCP"), - ("udp6", "UDP6"), - ("tcp6", "TCP6"), - ], required=False, - initial="udp", - ) - port_association_mode = forms.ChoiceField( - label="Port Association Mode", - choices=[ - ("ifIndex", "ifIndex"), - ("ifName", "ifName"), - ("ifDescr", "ifDescr"), - ("ifAlias", "ifAlias"), - ], - required=False, - initial="ifIndex", - help_text="Method to identify ports", - ) - poller_group = forms.ChoiceField( - label="Poller Group", - required=False, - help_text="Poller group for distributed poller setup", - ) - force_add = forms.BooleanField( - label="Force Add", - required=False, - initial=False, - help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)", ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["poller_group"].choices = _get_librenms_poller_group_choices() + def clean(self): + cleaned = super().clean() + authlevel = cleaned.get("authlevel") + + if authlevel in ("authNoPriv", "authPriv"): + if not cleaned.get("authpass"): + self.add_error("authpass", "Auth password is required for this auth level.") + if not cleaned.get("authalgo"): + self.add_error("authalgo", "Auth algorithm is required for this auth level.") + + if authlevel == "authPriv": + if not cleaned.get("cryptopass"): + self.add_error("cryptopass", "Crypto password is required for authPriv.") + if not cleaned.get("cryptoalgo"): + self.add_error("cryptoalgo", "Crypto algorithm is required for authPriv.") + + return cleaned + + +# Backwards-compatible alias β€” remove once all references are updated. +AddToLIbreSNMPV3 = AddToLibreSNMPV3 class DeviceStatusFilterForm(NetBoxModelFilterSetForm): diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 66e741b1d..93962432e 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -228,7 +228,7 @@ def bulk_import_devices_shared( for m in vc_data.get("members", []) ) if member_parts: - fingerprint = hashlib.md5(",".join(member_parts).encode()).hexdigest()[:12] + fingerprint = hashlib.sha256(",".join(member_parts).encode()).hexdigest()[:12] vc_domain = f"librenms-stack-{fingerprint}" else: vc_domain = f"librenms-{device_id}" @@ -357,7 +357,11 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server if hasattr(refreshed, "role") and refreshed.role: apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm"))) elif not validation.get("import_as_vm"): - validation["device_role"] = {"found": False, "role": None} + validation["device_role"] = { + "found": False, + "role": None, + "available_roles": validation.get("device_role", {}).get("available_roles", []), + } remove_validation_issue(validation, "role") recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm"))) # Re-assert non-importable state: recalculate bases can_import on @@ -374,7 +378,11 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server # Guard: VMs don't use device_role for readiness, so preserve any # user-selected role rather than silently dropping it. if not validation.get("import_as_vm"): - validation["device_role"] = {"found": False, "role": None} + validation["device_role"] = { + "found": False, + "role": None, + "available_roles": validation.get("device_role", {}).get("available_roles", []), + } recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm"))) except Exception as e: existing_id = getattr(existing, "pk", "unknown") if existing else "none" @@ -446,8 +454,16 @@ def _lookup_in_model(m): if not actual_is_vm and hasattr(new_device, "role") and new_device.role: apply_role_to_validation(validation, new_device.role, is_vm=False) elif not actual_is_vm: - validation["device_role"] = {"found": False, "role": None} + validation["device_role"] = { + "found": False, + "role": None, + "available_roles": validation.get("device_role", {}).get("available_roles", []), + } recalculate_validation_status(validation, is_vm=actual_is_vm) + # Re-assert non-importable: recalculate sets can_import from issues list, + # but a late-found existing match must never be import-ready. + validation["can_import"] = False + validation["is_ready"] = False except Exception as e: logger.error(f"Failed to check for newly imported device: {e}") diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py index b6860a3fe..3728e6a9b 100644 --- a/netbox_librenms_plugin/import_utils/cache.py +++ b/netbox_librenms_plugin/import_utils/cache.py @@ -179,7 +179,7 @@ def get_validated_device_cache_key( Example: >>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True) >>> key - 'validated_device_default_e3b0c44298fc1c14_123_vc' + 'validated_device_default_154d56ec9289d49c_123_vc_sysname=True_strip=False' """ # Sort filters for a deterministic, cross-process stable hash; None values are excluded # (consistent with get_cache_metadata_key). @@ -190,7 +190,7 @@ def get_validated_device_cache_key( ) -def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str: +def get_import_device_cache_key(device_id: int | str, server_key: str) -> str: """ Generate cache key for raw LibreNMS device data. @@ -200,7 +200,7 @@ def get_import_device_cache_key(device_id: int | str, server_key: str = "default Args: device_id: LibreNMS device ID - server_key: LibreNMS server identifier for multi-server setups. Defaults to "default" for backward compatibility. + server_key: LibreNMS server identifier for multi-server setups. Required. Returns: str: Cache key for the device data diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 152414998..6b87935b0 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -617,7 +617,18 @@ def validate_device_for_import( result["platform"] = platform_match if not platform_match["found"] and os: - result["warnings"].append(f"No matching platform found for OS: '{os}'") + if platform_match.get("match_type") == "ambiguous": + ambiguity_source = platform_match.get("ambiguity_source", "mapping") + if ambiguity_source == "platform": + result["warnings"].append( + f"Multiple Platforms match OS: '{os}' β€” resolve the duplicate Platform names in NetBox" + ) + else: + result["warnings"].append( + f"Multiple platform mappings found for OS: '{os}' β€” resolve the conflict in Platform Mappings" + ) + else: + result["warnings"].append(f"No matching platform found for OS: '{os}'") # 6. Additional validations if not hostname: @@ -738,7 +749,7 @@ def import_single_device( 'interfaces': int, 'cables': int, 'ip_addresses': int - } + }, } """ try: diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py index a1b68ef41..52b475b21 100644 --- a/netbox_librenms_plugin/import_utils/virtual_chassis.py +++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py @@ -216,6 +216,9 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d # indexing. Some vendors use 0-based positions (0,1,2,3,4) instead of the # RFC 2737 standard 1-based (1,2,3,4,5). If any raw position is 0, shift # all valid positions up by 1 so the resulting set is always 1-based. + # Exception: if *every* position is 0 the data is invalid (all members + # would collide on the same slot) β€” skip the shift and fall through to + # the per-member idx+1 fallback below. raw_positions = [] for chassis in chassis_items: raw = chassis.get("entPhysicalParentRelPos") @@ -225,7 +228,7 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d raw_positions.append(None) valid_positions = [p for p in raw_positions if p is not None] - zero_based = bool(valid_positions) and min(valid_positions) == 0 + zero_based = bool(valid_positions) and min(valid_positions) == 0 and max(valid_positions) > 0 # Identify the master member by matching the LibreNMS device serial # against the ENTITY-MIB serials. The device-level serial reported by @@ -592,7 +595,6 @@ def create_virtual_chassis_with_members( ) members_created += 1 - # Validate member count # Validate member count β€” exclude master-slot entries with blank serials expected_members = len( [ diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index a630b5563..99ae10a8a 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -151,6 +151,10 @@ def bulk_import_vms( # Use job logger if available, otherwise standard logger log = job.logger if job else logger + # Resolve options once before the loop β€” they do not change per-VM + use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True + strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False + for idx, vm_id in enumerate(vm_ids, start=1): # Check for job cancellation before first VM and every 5 thereafter if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job): @@ -173,8 +177,6 @@ def bulk_import_vms( continue # Validate as VM - use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True - strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False validation = validate_device_for_import( libre_device, import_as_vm=True, diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 457eea37f..86eba28f3 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -170,10 +170,37 @@ def get_available_servers(cls): return {"default": f"Default Server ({legacy_url})"} return {"default": "Default Server"} + def get_stored_librenms_id(self, obj): + """ + Return the stored or cached LibreNMS ID for an object without discovery. + + This helper is safe for generic NetBox objects such as interfaces, + where IP/hostname-based discovery would be expensive or incorrect. + + Args: + obj: NetBox object with a librenms_id custom field or cache identity + + Returns: + int: LibreNMS ID if found in the custom field or cache, None otherwise + """ + from netbox_librenms_plugin.utils import get_librenms_device_id + + librenms_id = get_librenms_device_id(obj, self.server_key, auto_save=False) + if librenms_id is not None: + return librenms_id + + # Check cache + cache_key = self._get_cache_key(obj) + librenms_id = cache.get(cache_key) + if librenms_id is not None: + return librenms_id + + return None + def get_librenms_id(self, obj): """ Args: - obj: NetBox device or VM object + obj: NetBox object with a librenms_id custom field or discovery identity Returns: int: LibreNMS device ID if found, None otherwise @@ -190,22 +217,16 @@ def get_librenms_id(self, obj): If found via API, stores ID in custom field if available, otherwise caches the value. """ - from netbox_librenms_plugin.utils import get_librenms_device_id - - librenms_id = get_librenms_device_id(obj, self.server_key, auto_save=False) - if librenms_id is not None: - return librenms_id - - # Check cache - cache_key = self._get_cache_key(obj) - librenms_id = cache.get(cache_key) + librenms_id = self.get_stored_librenms_id(obj) if librenms_id is not None: return librenms_id - # Determine dynamically from API - ip_address = obj.primary_ip.address.ip if obj.primary_ip else None - dns_name = obj.primary_ip.dns_name if obj.primary_ip else None - hostname = obj.name if obj.name else None + # Determine dynamically from API when the object exposes device identity fields. + primary_ip = getattr(obj, "primary_ip", None) + primary_ip_address = getattr(primary_ip, "address", None) + ip_address = getattr(primary_ip_address, "ip", None) if primary_ip else None + dns_name = getattr(primary_ip, "dns_name", None) if primary_ip else None + hostname = getattr(obj, "name", None) or None # Try IP address if ip_address: @@ -343,12 +364,18 @@ def get_device_info(self, device_id): timeout=DEFAULT_API_TIMEOUT, verify=self.verify_ssl, ) - if response.status_code == 200: - device_data = response.json()["devices"][0] - if not isinstance(device_data, dict): - return False, None - return True, device_data - return False, None + response.raise_for_status() + device_data = response.json()["devices"][0] + if not isinstance(device_data, dict): + return False, None + # LibreNMS 26.5.0 returns the full location relationship object + # (keys: id, location, lat, lng, timestamp, fixed_coordinates) + # instead of a flat location name string. Normalise to the name so + # downstream consumers receive a consistent value. + location = device_data.get("location") + if isinstance(location, dict): + device_data["location"] = location.get("location") + return True, device_data except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError): return False, None @@ -429,16 +456,15 @@ def add_device(self, data): if data["snmp_version"] in ("v1", "v2c"): payload["community"] = data["community"] elif data["snmp_version"] == "v3": - payload.update( - { - "authlevel": data["authlevel"], - "authname": data["authname"], - "authpass": data["authpass"], - "authalgo": data["authalgo"], - "cryptopass": data["cryptopass"], - "cryptoalgo": data["cryptoalgo"], - } - ) + payload["authlevel"] = data["authlevel"] + payload["authname"] = data["authname"] + # Credential keys only apply at the auth levels that use them. Omit + # empty values instead of sending empty strings β€” LibreNMS rejects + # those for noAuthNoPriv / authNoPriv add-device requests. + for key in ("authpass", "authalgo", "cryptopass", "cryptoalgo"): + value = data.get(key) + if value: + payload[key] = value try: response = requests.post( @@ -711,6 +737,69 @@ def get_device_inventory(self, device_id): except (requests.exceptions.RequestException, ValueError) as e: return False, str(e) + def get_device_transceivers(self, device_id): + """ + Fetch all transceiver data for a device from LibreNMS. + + Route: /api/v0/devices/{device_id}/transceivers + + This is a separate data source from entity inventory. Some vendors + (e.g., Nokia/SROS) don't expose SFPs via ENTITY-MIB but do report + them through vendor-specific MIBs which LibreNMS surfaces here. + + Args: + device_id: LibreNMS device ID + + Returns: + tuple: (success: bool, data: list) + + Example transceiver item: + { + "port_id": 519, + "entity_physical_index": 1610899520, + "type": "CFP2/QSFP28", + "model": "3HE10550AARA01", + "serial": "X42AU0D", + "channels": 4, + "connector": "LC", + "wavelength": 1301, + ... + } + """ + try: + response = requests.get( + f"{self.librenms_url}/api/v0/devices/{device_id}/transceivers", + headers=self.headers, + timeout=DEFAULT_API_TIMEOUT, + verify=self.verify_ssl, + ) + response.raise_for_status() + + try: + data = response.json() + except ValueError: + return False, f"Invalid JSON in transceivers response for device {device_id}" + + if not isinstance(data, dict) or "transceivers" not in data: + msg = data.get("message") if isinstance(data, dict) else None + return False, msg or f"Unexpected transceivers response format for device {device_id}" + + if data.get("status") != "ok": + msg = data.get("message") or f"LibreNMS returned status={data.get('status')!r} for device {device_id}" + return False, msg + + transceivers = data["transceivers"] + if not isinstance(transceivers, list): + msg = data.get("message") + return False, msg or f"Unexpected transceivers response format for device {device_id}" + + if any(item is None or not isinstance(item, dict) for item in transceivers): + return False, f"Malformed transceiver entry in response for device {device_id}" + + return True, transceivers + except requests.exceptions.RequestException as e: + return False, str(e) + def get_poller_groups(self): """ Fetch all poller groups from LibreNMS. @@ -955,14 +1044,15 @@ def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]: if not isinstance(all_vlans, list): msg = result.get("message") return False, msg or "Unexpected response format: missing 'vlans' list" + if not all(isinstance(v, dict) for v in all_vlans): + return False, "Unexpected response format: invalid item shape in 'vlans'" # Filter VLANs by device_id since resources endpoint returns all VLANs - device_vlans = [ - v for v in all_vlans if isinstance(v, dict) and str(v.get("device_id")) == str(device_id) - ] + device_vlans = [v for v in all_vlans if str(v.get("device_id")) == str(device_id)] return True, device_vlans if isinstance(result, dict): return False, result.get("message") or "Unexpected response format" return False, "Unexpected response format" + except requests.exceptions.HTTPError as e: if e.response.status_code == 404: return False, "VLANs resource not found" @@ -1005,20 +1095,21 @@ def get_port_vlan_details(self, port_id: int) -> tuple[bool, dict | str]: ) response.raise_for_status() - if response.status_code == 200: - result = response.json() - if not isinstance(result, dict): - return False, "Unexpected response format" - port_data = result.get("port") - if not isinstance(port_data, list): - return False, result.get("message", "Unexpected response format: missing 'port' list") - if not port_data: - return False, "Port not found" - if not isinstance(port_data[0], dict): - return False, "Unexpected response format: invalid 'port' entry" - return True, port_data[0] - - return False, f"HTTP {response.status_code}" + result = response.json() + if not isinstance(result, dict): + return False, "Unexpected response format" + if result.get("status") != "ok": + msg = result.get("message") or f"LibreNMS returned status={result.get('status')!r} for port" + return False, msg + port_data = result.get("port") + if not isinstance(port_data, list): + return False, result.get("message") or "Unexpected response format: missing 'port' list" + if not port_data: + return False, "Port not found" + if not isinstance(port_data[0], dict): + return False, "Unexpected response format: invalid 'port' entry" + return True, port_data[0] + except requests.exceptions.HTTPError as e: if e.response.status_code == 404: return False, "Port not found in LibreNMS" diff --git a/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py b/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py new file mode 100644 index 000000000..9ef60b11d --- /dev/null +++ b/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py @@ -0,0 +1,482 @@ +# Consolidated inventory + mapping models migration. +# +# This single migration creates every model and constraint introduced by the +# inventory-core feature set: DeviceTypeMapping, ModuleTypeMapping, +# ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping, +# CarrierAutoInstallRule, and the wildcard/global uniqueness constraints on +# the mapping tables. It also seeds two default InventoryIgnoreRule entries +# that the modules-sync code relies on (Cisco IOS-XR IDPROM duplicates and +# embedded RP/fixed-chassis system boards). +# +# Earlier branches contained six separate migrations (0010..0015) for the same +# end state; they were squashed before merge because they only ever shipped to +# the devcontainer, never to a release. If you have an environment with the +# old 0010..0015 history applied, run: +# +# python manage.py migrate netbox_librenms_plugin 0009 --fake +# python manage.py migrate netbox_librenms_plugin --fake +# +# to rewrite the migration history without touching the schema (the end state +# is identical), then run regular ``migrate`` for any subsequent migrations. + +import django.db.models.deletion +import netbox.models.deletion +import netbox_librenms_plugin.models +import taggit.managers +import utilities.json +from django.db import migrations, models + + +def _insert_default_inventory_ignore_rules(apps, schema_editor): + """Seed the two InventoryIgnoreRules the modules sync expects out of the box.""" + db_alias = schema_editor.connection.alias + InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") + InventoryIgnoreRule.objects.using(db_alias).create( + name="Cisco IOS-XR IDPROM entries", + match_type="ends_with", + pattern="IDPROM", + action="skip", + require_serial_match_parent=True, + enabled=True, + description=( + "Cisco IOS-XR reports every hardware component's EEPROM as a child entity " + 'whose entPhysicalName ends in "IDPROM". These entries duplicate the parent ' + "module's serial number and are not real installable modules. " + "The serial-match guard ensures only genuine EEPROM duplicates are skipped \u2014 " + 'a module whose name happens to end in "IDPROM" but has a different serial ' + "will not be filtered." + ), + ) + InventoryIgnoreRule.objects.using(db_alias).create( + name="Embedded RP / fixed-chassis system board", + match_type="serial_matches_device", + pattern="", + action="transparent", + require_serial_match_parent=False, + enabled=True, + description=( + "Fixed-form routers report the built-in RP as an ENTITY-MIB module whose " + "serial number equals the device's own serial. Marking it transparent hides " + "the RP row in the sync table while promoting its children (transceivers, " + "fans, PSUs) to device-level bay matching. No pattern is needed \u2014 detection " + "is purely serial-based." + ), + ) + + +def _delete_default_inventory_ignore_rules(apps, schema_editor): + """Reverse the seed by matching each rule on its full signature. + + Filtering by name alone would also remove user-created rules that happen + to share the seeded name (``InventoryIgnoreRule.name`` is not unique), so + we match each seeded row on the distinctive fields that uniquely identify + it as the migration's own insert. + """ + db_alias = schema_editor.connection.alias + InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") + seeded = ( + { + "name": "Cisco IOS-XR IDPROM entries", + "match_type": "ends_with", + "pattern": "IDPROM", + "action": "skip", + "require_serial_match_parent": True, + }, + { + "name": "Embedded RP / fixed-chassis system board", + "match_type": "serial_matches_device", + "pattern": "", + "action": "transparent", + "require_serial_match_parent": False, + }, + ) + for signature in seeded: + InventoryIgnoreRule.objects.using(db_alias).filter(**signature).delete() + + +class Migration(migrations.Migration): + dependencies = [ + # Pinned to the NetBox 4.2 floor declared by ``min_version`` in + # ``netbox_librenms_plugin/__init__.py``. Only ``Manufacturer``, + # ``DeviceType``, ``ModuleType``, and ``Platform`` are referenced from + # dcim, and only ``Tag``/``TaggedItem`` from extras (via taggit) β€” all + # of which exist in 4.2.x. ``makemigrations`` will try to bump these to + # the dev environment's NetBox tip; revert it unless we actually start + # depending on a newer field. + ("dcim", "0200_populate_mac_addresses"), + ("extras", "0122_charfield_null_choices"), + ("netbox_librenms_plugin", "0009_convert_librenms_id_to_json"), + ] + + operations = [ + migrations.CreateModel( + name="CarrierAutoInstallRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("device_type_pattern", models.CharField(blank=True, max_length=255)), + ("librenms_child_class", models.CharField(max_length=50)), + ("librenms_child_name_pattern", models.CharField(max_length=255)), + ("netbox_bay_name_pattern", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["manufacturer__name", "librenms_child_class", "librenms_child_name_pattern"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="DeviceTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_hardware", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["librenms_hardware"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="InventoryIgnoreRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("name", models.CharField(max_length=100)), + ("match_type", models.CharField(default="ends_with", max_length=25)), + ("pattern", models.CharField(blank=True, max_length=200)), + ("action", models.CharField(default="skip", max_length=15)), + ("require_serial_match_parent", models.BooleanField(default=True)), + ("enabled", models.BooleanField(db_index=True, default=True)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["name", "pk"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="ModuleBayMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_name", models.CharField(max_length=255)), + ("librenms_class", models.CharField(blank=True, max_length=50)), + ("netbox_bay_name", models.CharField(max_length=255)), + ("is_regex", models.BooleanField(default=False)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["librenms_name"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="ModuleTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_model", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["librenms_model"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="NormalizationRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("scope", models.CharField(db_index=True, max_length=50)), + ("match_pattern", models.CharField(max_length=500)), + ("replacement", models.CharField(max_length=500)), + ("priority", models.PositiveIntegerField(default=100)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["scope", "priority", "pk"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.CreateModel( + name="PlatformMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_os", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ], + options={ + "ordering": ["librenms_os"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.AlterModelOptions( + name="interfacetypemapping", + options={"ordering": ["librenms_type", "librenms_speed"]}, + ), + migrations.AlterUniqueTogether( + name="interfacetypemapping", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="interfacetypemapping", + constraint=models.UniqueConstraint( + condition=models.Q(("librenms_speed__isnull", False)), + fields=("librenms_type", "librenms_speed"), + name="unique_interface_type_mapping", + ), + ), + migrations.AddConstraint( + model_name="interfacetypemapping", + constraint=models.UniqueConstraint( + condition=models.Q(("librenms_speed__isnull", True)), + fields=("librenms_type",), + name="unique_interface_type_mapping_wildcard", + ), + ), + migrations.AddField( + model_name="carrierautoinstallrule", + name="carrier_module_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="librenms_carrier_install_rules", + to="dcim.moduletype", + ), + ), + migrations.AddField( + model_name="carrierautoinstallrule", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="librenms_carrier_install_rules", + to="dcim.manufacturer", + ), + ), + migrations.AddField( + model_name="carrierautoinstallrule", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="devicetypemapping", + name="netbox_device_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_device_type_mappings", + to="dcim.devicetype", + ), + ), + migrations.AddField( + model_name="devicetypemapping", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="inventoryignorerule", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="modulebaymapping", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="librenms_module_bay_mappings", + to="dcim.manufacturer", + ), + ), + migrations.AddField( + model_name="modulebaymapping", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="moduletypemapping", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="librenms_module_type_mappings", + to="dcim.manufacturer", + ), + ), + migrations.AddField( + model_name="moduletypemapping", + name="netbox_module_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_module_type_mappings", + to="dcim.moduletype", + ), + ), + migrations.AddField( + model_name="moduletypemapping", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="normalizationrule", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="normalization_rules", + to="dcim.manufacturer", + ), + ), + migrations.AddField( + model_name="normalizationrule", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddField( + model_name="platformmapping", + name="netbox_platform", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_platform_mappings", + to="dcim.platform", + ), + ), + migrations.AddField( + model_name="platformmapping", + name="tags", + field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + migrations.AddConstraint( + model_name="carrierautoinstallrule", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", False)), + fields=( + "manufacturer", + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + ), + name="unique_carrier_auto_install_rule", + ), + ), + migrations.AddConstraint( + model_name="carrierautoinstallrule", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", True)), + fields=( + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + ), + name="unique_carrier_auto_install_rule_global", + ), + ), + migrations.AddConstraint( + model_name="modulebaymapping", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", False)), + fields=("librenms_name", "librenms_class", "manufacturer"), + name="unique_module_bay_mapping", + ), + ), + migrations.AddConstraint( + model_name="modulebaymapping", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", True)), + fields=("librenms_name", "librenms_class"), + name="unique_module_bay_mapping_global", + ), + ), + migrations.AddConstraint( + model_name="moduletypemapping", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", False)), + fields=("librenms_model", "manufacturer"), + name="unique_module_type_mapping", + ), + ), + migrations.AddConstraint( + model_name="moduletypemapping", + constraint=models.UniqueConstraint( + condition=models.Q(("manufacturer__isnull", True)), + fields=("librenms_model",), + name="unique_module_type_mapping_global", + ), + ), + migrations.RunPython( + _insert_default_inventory_ignore_rules, + reverse_code=_delete_default_inventory_ignore_rules, + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f4755..16c8b6895 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -1,8 +1,53 @@ +import functools +import logging +import re + +import yaml from dcim.choices import InterfaceTypeChoices +from dcim.models import DeviceType, Manufacturer, ModuleType, Platform +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from netbox.models import NetBoxModel +logger = logging.getLogger(__name__) + + +def _validate_replacement_template(compiled: re.Pattern, replacement: str) -> None: + """Verify that *replacement* is a valid back-reference template for *compiled*. + + ``re.sub(pattern, replacement, test_string)`` only evaluates group references + when the pattern actually matches the test string. Using the pattern text + itself as the test string may silently accept an invalid replacement when the + pattern does not match its own source (e.g. ``^(\\d+)$`` never matches the + string ``^(\\d+)$``). + + This function constructs a synthetic test pattern with one trivial capture + group per group in *compiled* (named groups are preserved so ``\\g`` + references are validated correctly), guaranteeing a match and ensuring all + back-references are exercised. + + Raises ``re.error`` or ``IndexError`` if the replacement is invalid. + """ + n = compiled.groups + if n == 0: + test_pat = re.compile("a") + test_str = "a" + else: + name_by_pos = {v: k for k, v in compiled.groupindex.items()} + parts = [f"(?P<{name_by_pos[i]}>a)" if i in name_by_pos else "(a)" for i in range(1, n + 1)] + test_pat = re.compile("".join(parts)) + test_str = "a" * n + test_pat.sub(replacement, test_str) + + +class FullCleanOnSaveMixin: + """Mixin that calls full_clean() on every save() so custom clean() logic runs even on programmatic saves.""" + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + class LibreNMSSettings(models.Model): """ @@ -34,6 +79,10 @@ class LibreNMSSettings(models.Model): help_text="Remove domain suffix from device names during import", ) + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + class Meta: """Meta options for LibreNMSSettings.""" @@ -48,7 +97,7 @@ def __str__(self): return f"LibreNMS Settings - Server: {self.selected_server}" -class InterfaceTypeMapping(NetBoxModel): +class InterfaceTypeMapping(FullCleanOnSaveMixin, NetBoxModel): """Map LibreNMS interface types and speeds to NetBox interface types.""" librenms_type = models.CharField(max_length=100) @@ -63,6 +112,27 @@ class InterfaceTypeMapping(NetBoxModel): help_text="Optional description or notes about this interface type mapping", ) + def clean(self): + """Enforce uniqueness for NULL-speed rows (SQL UNIQUE does not cover NULL = NULL).""" + from django.core.exceptions import ValidationError + + super().clean() + normalized_type = (self.librenms_type or "").strip() + if not normalized_type: + raise ValidationError({"librenms_type": "LibreNMS type must not be blank or whitespace-only."}) + self.librenms_type = normalized_type + if self.librenms_speed is None: + qs = InterfaceTypeMapping.objects.filter( + librenms_type=normalized_type, + librenms_speed__isnull=True, + ) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + {"librenms_type": ("A wildcard (speed = any) mapping for this interface type already exists.")} + ) + def get_absolute_url(self): """Return the URL for this mapping's detail page.""" return reverse("plugins:netbox_librenms_plugin:interfacetypemapping_detail", args=[self.pk]) @@ -70,7 +140,789 @@ def get_absolute_url(self): class Meta: """Meta options for InterfaceTypeMapping.""" - unique_together = ["librenms_type", "librenms_speed"] + constraints = [ + models.UniqueConstraint( + fields=["librenms_type", "librenms_speed"], + condition=models.Q(librenms_speed__isnull=False), + name="unique_interface_type_mapping", + ), + models.UniqueConstraint( + fields=["librenms_type"], + condition=models.Q(librenms_speed__isnull=True), + name="unique_interface_type_mapping_wildcard", + ), + ] + ordering = ["librenms_type", "librenms_speed"] def __str__(self): return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}" + + def to_yaml(self): + data = { + "librenms_type": self.librenms_type, + "librenms_speed": self.librenms_speed, + "netbox_type": self.netbox_type, + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class DeviceTypeMapping(FullCleanOnSaveMixin, NetBoxModel): + """Map LibreNMS hardware strings to NetBox DeviceType objects.""" + + librenms_hardware = models.CharField( + max_length=255, + unique=True, + help_text="Hardware string as reported by LibreNMS (e.g., 'Juniper MX480 Internet Backbone Router')", + ) + netbox_device_type = models.ForeignKey( + DeviceType, + on_delete=models.CASCADE, + related_name="librenms_device_type_mappings", + help_text="The NetBox DeviceType this hardware string maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Normalize librenms_hardware to lowercase so case-variant duplicates are prevented at save time.""" + super().clean() + self.librenms_hardware = (self.librenms_hardware or "").strip().lower() + if not self.librenms_hardware: + raise ValidationError({"librenms_hardware": "This field may not be blank after normalization."}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:devicetypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for DeviceTypeMapping.""" + + ordering = ["librenms_hardware"] + + def __str__(self): + return f"{self.librenms_hardware} -> {self.netbox_device_type}" + + def to_yaml(self): + data = { + "librenms_hardware": self.librenms_hardware, + "netbox_device_type": str(self.netbox_device_type), + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class ModuleTypeMapping(FullCleanOnSaveMixin, NetBoxModel): + """Map LibreNMS inventory model names to NetBox ModuleType objects.""" + + librenms_model = models.CharField( + max_length=255, + help_text="Model name from LibreNMS inventory (entPhysicalModelName)", + ) + netbox_module_type = models.ForeignKey( + ModuleType, + on_delete=models.CASCADE, + related_name="librenms_module_type_mappings", + help_text="The NetBox ModuleType this model name maps to", + ) + manufacturer = models.ForeignKey( + Manufacturer, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="librenms_module_type_mappings", + help_text=( + "Optional: scope this mapping to one manufacturer (matches the device's " + "device_type.manufacturer). Leave blank to apply across vendors. When both a " + "manufacturer-scoped and a global mapping exist for the same librenms_model, " + "the manufacturer-scoped row wins for devices of that vendor." + ), + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Normalize librenms_model so whitespace-padded values don't create duplicate entries.""" + super().clean() + self.librenms_model = (self.librenms_model or "").strip() + if not self.librenms_model: + raise ValidationError({"librenms_model": "This field may not be blank after normalization."}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:moduletypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleTypeMapping.""" + + ordering = ["librenms_model"] + # UniqueConstraint over (librenms_model, manufacturer): + # PostgreSQL <13 treats NULL values as distinct in unique indexes, so + # the nullable manufacturer FK lets two "global" rows (manufacturer + # IS NULL) with the same librenms_model coexist. Splitting into a + # conditional pair (NOT NULL + NULL) makes the constraint enforce + # uniqueness in both cases on every supported PG version. + constraints = [ + models.UniqueConstraint( + fields=["librenms_model", "manufacturer"], + condition=models.Q(manufacturer__isnull=False), + name="unique_module_type_mapping", + ), + models.UniqueConstraint( + fields=["librenms_model"], + condition=models.Q(manufacturer__isnull=True), + name="unique_module_type_mapping_global", + ), + ] + + def __str__(self): + mfr = f" ({self.manufacturer.name})" if self.manufacturer_id else "" + return f"{self.librenms_model}{mfr} -> {self.netbox_module_type}" + + def to_yaml(self): + data = { + "librenms_model": self.librenms_model, + "manufacturer": self.manufacturer.name if self.manufacturer_id else "", + "netbox_module_type": str(self.netbox_module_type), + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class ModuleBayMapping(FullCleanOnSaveMixin, NetBoxModel): + """ + Map LibreNMS inventory names to NetBox module bay names. + + Used when LibreNMS inventory names don't match NetBox bay names exactly. + For example: LibreNMS "Power Supply 1" β†’ NetBox "PS1". + When is_regex is True, librenms_name is treated as a regex pattern and + netbox_bay_name can use backreferences (\\1, \\2, etc.). + Mappings are global (not scoped to device type or manufacturer). + """ + + librenms_name = models.CharField( + max_length=255, + help_text="Name from LibreNMS inventory (entPhysicalName). " + "When 'Use Regex' is enabled, this is a Python regex pattern.", + ) + librenms_class = models.CharField( + max_length=50, + blank=True, + help_text="Optional entPhysicalClass filter (e.g. 'powerSupply', 'fan', 'module')", + ) + netbox_bay_name = models.CharField( + max_length=255, + help_text="NetBox module bay name to match. With regex, supports backreferences (\\1, \\2, etc.).", + ) + is_regex = models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ) + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="librenms_module_bay_mappings", + help_text="Optional: scope this mapping to one manufacturer (matches the device's " + "device_type.manufacturer). Leave blank to apply across vendors. When both a " + "vendor-scoped and a global mapping match, the vendor-scoped one wins.", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + @functools.cached_property + def _compiled_pattern(self): + """Compiled regex for is_regex=True mappings; None for exact mappings or invalid patterns.""" + if not self.is_regex or not self.librenms_name: + return None + try: + return re.compile(self.librenms_name) + except re.error: + return None + + def clean(self): + """Validate that regex patterns compile when is_regex is True.""" + super().clean() + # Invalidate cached compiled pattern so it's recomputed from the new value + self.__dict__.pop("_compiled_pattern", None) + librenms_name_stripped = self.librenms_name.strip() if self.librenms_name else "" + if not librenms_name_stripped: + raise ValidationError({"librenms_name": "LibreNMS name pattern must not be empty or whitespace-only."}) + self.librenms_name = librenms_name_stripped + # Strip class too β€” whitespace-padded values form spurious distinct rows under unique_together. + self.librenms_class = self.librenms_class.strip() if self.librenms_class else "" + # Strip netbox_bay_name β€” whitespace-padded values would fail regex substitution. + netbox_bay_name_stripped = self.netbox_bay_name.strip() if self.netbox_bay_name else "" + if not netbox_bay_name_stripped: + raise ValidationError({"netbox_bay_name": "NetBox bay name must not be empty or whitespace-only."}) + self.netbox_bay_name = netbox_bay_name_stripped + if self.is_regex: + try: + pattern = re.compile(self.librenms_name) + except re.error as e: + raise ValidationError({"librenms_name": f"Invalid regex: {e}"}) + try: + _validate_replacement_template(pattern, self.netbox_bay_name) + except (re.error, IndexError) as e: + raise ValidationError({"netbox_bay_name": f"Invalid replacement: {e}"}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:modulebaymapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleBayMapping.""" + + # Two conditional UniqueConstraints rather than a single + # UniqueConstraint over (librenms_name, librenms_class, manufacturer): + # PostgreSQL treats NULL β‰  NULL, so a single constraint that includes + # the nullable manufacturer FK lets two "global" rows (manufacturer + # IS NULL) with otherwise identical fields slip through. The + # cleaner ``nulls_distinct=False`` option requires PostgreSQL 15+ + # (Django 5.2+), but NetBox 4.2 still supports PostgreSQL 12, so we + # split into one constraint per branch instead. Issue #71. + constraints = [ + models.UniqueConstraint( + fields=["librenms_name", "librenms_class", "manufacturer"], + condition=models.Q(manufacturer__isnull=False), + name="unique_module_bay_mapping", + ), + models.UniqueConstraint( + fields=["librenms_name", "librenms_class"], + condition=models.Q(manufacturer__isnull=True), + name="unique_module_bay_mapping_global", + ), + ] + ordering = ["librenms_name"] + + def __str__(self): + cls = f" [{self.librenms_class}]" if self.librenms_class else "" + mfr = f" ({self.manufacturer.name})" if self.manufacturer_id else "" + return f"{self.librenms_name}{cls}{mfr} -> {self.netbox_bay_name}" + + def to_yaml(self): + data = { + "librenms_name": self.librenms_name, + "librenms_class": self.librenms_class, + "netbox_bay_name": self.netbox_bay_name, + "is_regex": self.is_regex, + "manufacturer": self.manufacturer.name if self.manufacturer_id else "", + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class NormalizationRule(FullCleanOnSaveMixin, NetBoxModel): + """ + Regex-based string normalization applied before matching lookups. + + Generic building block: a single rule engine handles normalization + for module types, device types, module bays, and future scopes. + Rules are applied in priority order; each transforms the string + for the next rule in the chain. + + Example – strip Nokia revision suffixes: + scope: module_type + match_pattern: ^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$ + replacement: \\1 + Result: 3HE16474AARA01 β†’ 3HE16474AA + """ + + SCOPE_MODULE_TYPE = "module_type" + SCOPE_DEVICE_TYPE = "device_type" + SCOPE_MODULE_BAY = "module_bay" + + SCOPE_CHOICES = [ + (SCOPE_MODULE_TYPE, "Module Type"), + (SCOPE_DEVICE_TYPE, "Device Type"), + (SCOPE_MODULE_BAY, "Module Bay"), + ] + + scope = models.CharField( + max_length=50, + choices=SCOPE_CHOICES, + db_index=True, + help_text="Which matching lookup this rule applies to", + ) + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="normalization_rules", + help_text="Optional: only apply this rule to items from this manufacturer. " + "Leave blank for vendor-agnostic rules.", + ) + match_pattern = models.CharField( + max_length=500, + help_text="Regex pattern to match against input string (Python re syntax)", + ) + replacement = models.CharField( + max_length=500, + help_text="Replacement string (supports regex back-references \\1, \\2, …)", + ) + priority = models.PositiveIntegerField( + default=100, + help_text="Lower values run first. Rules chain: each transforms the output of the previous.", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this rule", + ) + + def clean(self): + """Validate that match_pattern compiles as a regex and replacement is a valid template.""" + super().clean() + errors = {} + if not self.match_pattern: + errors["match_pattern"] = "This field is required." + if self.replacement is None: + errors["replacement"] = "This field is required." + if errors: + raise ValidationError(errors) + try: + compiled = re.compile(self.match_pattern) + except re.error as e: + raise ValidationError({"match_pattern": f"Invalid regex: {e}"}) + # Validate the replacement template by running a dummy substitution + try: + _validate_replacement_template(compiled, self.replacement) + except (re.error, IndexError) as e: + raise ValidationError({"replacement": f"Invalid replacement template: {e}"}) + + def get_absolute_url(self): + """Return the URL for this rule's detail page.""" + return reverse("plugins:netbox_librenms_plugin:normalizationrule_detail", args=[self.pk]) + + class Meta: + """Meta options for NormalizationRule.""" + + ordering = ["scope", "priority", "pk"] + + def __str__(self): + return f"[{self.get_scope_display()}] {self.match_pattern} β†’ {self.replacement}" + + def to_yaml(self): + data = { + "scope": self.scope, + "manufacturer": str(self.manufacturer) if self.manufacturer else None, + "match_pattern": self.match_pattern, + "replacement": self.replacement, + "priority": self.priority, + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class InventoryIgnoreRule(FullCleanOnSaveMixin, NetBoxModel): + """ + Rule-based filter for ENTITY-MIB inventory items during module sync. + + Two use-cases are supported, controlled by the ``action`` field: + + **Skip** (``action='skip'``) + The matched item is removed from the sync table entirely. Used for + phantom EEPROM/IDPROM child entities that Cisco IOS-XR reports with the + same model and serial as the real parent module. + + **Transparent** (``action='transparent'``) + The matched item's row is hidden, but its ENTITY-MIB children are + *promoted* to device-level bay matching instead of being treated as + sub-components. Used for fixed-chassis devices (e.g. Cisco 8201-SYS) + where the RP/system-board entity is the device itself β€” it carries the + same serial number as the NetBox device, so its children (transceivers, + fans, PSUs) should be matched directly against device-level bays. + + Match types: + ``ends_with / starts_with / contains / regex`` + Compare ``entPhysicalName``. Use ``require_serial_match_parent`` + as a safety net to avoid false positives. + ``serial_matches_device`` + Match when the item's ``entPhysicalSerialNum`` equals the NetBox + device's own serial number. No ``pattern`` is required. + Pair with ``action='transparent'`` for embedded-RP detection. + """ + + # --- action --- + ACTION_SKIP = "skip" + ACTION_TRANSPARENT = "transparent" + ACTION_CHOICES = [ + (ACTION_SKIP, "Skip (remove from table)"), + (ACTION_TRANSPARENT, "Transparent (hide row, promote children to device level)"), + ] + + # --- match_type --- + MATCH_ENDS_WITH = "ends_with" + MATCH_STARTS_WITH = "starts_with" + MATCH_CONTAINS = "contains" + MATCH_REGEX = "regex" + MATCH_SERIAL_DEVICE = "serial_matches_device" + + MATCH_TYPE_CHOICES = [ + (MATCH_ENDS_WITH, "Ends with (entPhysicalName)"), + (MATCH_STARTS_WITH, "Starts with (entPhysicalName)"), + (MATCH_CONTAINS, "Contains (entPhysicalName)"), + (MATCH_REGEX, "Regex (entPhysicalName)"), + (MATCH_SERIAL_DEVICE, "Serial matches device (entPhysicalSerialNum = Device.serial)"), + ] + + name = models.CharField( + max_length=100, + help_text="Short descriptive label for this rule", + ) + match_type = models.CharField( + max_length=25, + choices=MATCH_TYPE_CHOICES, + default=MATCH_ENDS_WITH, + help_text="How to match the inventory item", + ) + pattern = models.CharField( + max_length=200, + blank=True, + help_text="Pattern to match against entPhysicalName. " + "Case-insensitive for ends_with / starts_with / contains; " + "Python re syntax for regex. " + "Not used for serial_matches_device.", + ) + action = models.CharField( + max_length=15, + choices=ACTION_CHOICES, + default=ACTION_SKIP, + help_text="What to do when this rule matches: skip the item entirely, " + "or hide its row and promote its children to device-level bay matching.", + ) + require_serial_match_parent = models.BooleanField( + default=True, + help_text="(Name-based rules only) Only apply this rule if the item's serial " + "number matches an ancestor entity's serial number. Recommended to " + "prevent false positives. Ignored for serial_matches_device rules.", + ) + enabled = models.BooleanField( + default=True, + db_index=True, + help_text="Uncheck to temporarily disable this rule without deleting it", + ) + description = models.TextField( + blank=True, + help_text="Optional notes about this rule (vendor, firmware version, etc.)", + ) + + def clean(self): + """Validate pattern/match_type consistency.""" + super().clean() + # Invalidate cached compiled pattern so it's recomputed from the new value + self.__dict__.pop("_compiled_pattern", None) + pattern_stripped = self.pattern.strip() if self.pattern else "" + if self.match_type == self.MATCH_REGEX and pattern_stripped: + try: + re.compile(pattern_stripped) + except re.error as e: + raise ValidationError({"pattern": f"Invalid regex: {e}"}) + if self.match_type != self.MATCH_SERIAL_DEVICE and not pattern_stripped: + raise ValidationError({"pattern": "Pattern is required for name-based match types."}) + # Normalize stored pattern to the stripped form so matches_name() and + # clean() always operate on the same string. + self.pattern = pattern_stripped + + @functools.cached_property + def _compiled_pattern(self): + """Compiled regex for MATCH_REGEX rules; None for other match types or invalid patterns.""" + if self.match_type != self.MATCH_REGEX or not self.pattern: + return None + try: + return re.compile(self.pattern) + except re.error: + return None + + def matches_name(self, name: str) -> bool: + """Return True if *name* matches this rule's pattern/match_type (name-based rules only).""" + if not name or self.match_type == self.MATCH_SERIAL_DEVICE: + return False + if not self.pattern or not self.pattern.strip(): + return False + if self.match_type == self.MATCH_REGEX: + compiled = self._compiled_pattern + if compiled is None: + logger.error( + "Invalid regex in InventoryIgnoreRule pk=%s pattern=%r β€” skipping", + self.pk, + self.pattern, + ) + return False + try: + return bool(compiled.search(name)) + except re.error as exc: + logger.error( + "Regex error in InventoryIgnoreRule pk=%s pattern=%r name=%r: %s β€” skipping", + self.pk, + self.pattern, + name, + exc, + ) + return False + name_up = name.upper() + pat = self.pattern.upper() + if self.match_type == self.MATCH_ENDS_WITH: + return name_up.endswith(pat) + if self.match_type == self.MATCH_STARTS_WITH: + return name_up.startswith(pat) + if self.match_type == self.MATCH_CONTAINS: + return pat in name_up + return False + + def get_absolute_url(self): + """Return the URL for this rule's detail page.""" + return reverse("plugins:netbox_librenms_plugin:inventoryignorerule_detail", args=[self.pk]) + + class Meta: + """Meta options for InventoryIgnoreRule.""" + + ordering = ["name", "pk"] + + def __str__(self): + if self.match_type == self.MATCH_SERIAL_DEVICE: + return f"{self.name}: {self.get_match_type_display()}" + serial_note = " [serial match]" if self.require_serial_match_parent else "" + return f"{self.name}: {self.get_match_type_display()} '{self.pattern}'{serial_note}" + + def to_yaml(self): + data = { + "name": self.name, + "match_type": self.match_type, + "pattern": self.pattern, + "action": self.action, + "require_serial_match_parent": self.require_serial_match_parent, + "enabled": self.enabled, + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class PlatformMapping(FullCleanOnSaveMixin, NetBoxModel): + """Map LibreNMS OS strings to NetBox Platform objects.""" + + librenms_os = models.CharField( + max_length=255, + unique=True, + help_text="OS string as reported by LibreNMS (e.g., 'ios', 'eos', 'junos')", + ) + netbox_platform = models.ForeignKey( + Platform, + on_delete=models.CASCADE, + related_name="librenms_platform_mappings", + help_text="The NetBox Platform this OS string maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Normalize librenms_os to lowercase so case-variant duplicates are prevented at save time.""" + super().clean() + self.librenms_os = (self.librenms_os or "").strip().lower() + if not self.librenms_os: + raise ValidationError({"librenms_os": "This field may not be blank after normalization."}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:platformmapping_detail", args=[self.pk]) + + class Meta: + """Meta options for PlatformMapping.""" + + ordering = ["librenms_os"] + + def __str__(self): + return f"{self.librenms_os} -> {self.netbox_platform}" + + def to_yaml(self): + data = { + "librenms_os": self.librenms_os, + "netbox_platform": str(self.netbox_platform), + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + +class CarrierAutoInstallRule(FullCleanOnSaveMixin, NetBoxModel): + """ + User-configurable suggestion rule for missing holder/carrier modules. + + Some chassis (e.g. Nokia 7750 SR-s with CMA controller carriers, mezzanine + carriers, line-card cassettes) expose a holder bay at the chassis level + whose nested child bays only become available once the carrier ModuleType + is installed in NetBox. LibreNMS does not report the carrier itself, only + its children (CPMs, MDAs, mezzanines), so they appear as orphan "No Bay" + rows. A rule lets the user say: "for Nokia 7750 SR chassis, when LibreNMS + reports a cpmModule named '^Slot [AB]$' and the chassis has an empty bay + named '^CMA$', suggest installing carrier_module_type into that bay." + + Suggest-only β€” the user clicks an Install button to apply. No vendor data + ships with the plugin; rules are loaded from the UI or contrib YAML. + """ + + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="librenms_carrier_install_rules", + help_text="Optional: scope this rule to one manufacturer (matches the device's " + "device_type.manufacturer). Leave blank to apply across vendors.", + ) + device_type_pattern = models.CharField( + max_length=255, + blank=True, + help_text="Optional regex (Python re.fullmatch) on the device_type model name. " + "Leave blank to apply to all device types of the selected manufacturer.", + ) + librenms_child_class = models.CharField( + max_length=50, + help_text="Exact entPhysicalClass match for the orphan child reported by LibreNMS " + "(e.g. cpmModule, mdaModule, fabricModule).", + ) + librenms_child_name_pattern = models.CharField( + max_length=255, + help_text="Regex (Python re.fullmatch) on the orphan child's entPhysicalName (e.g. '^Slot [AB]$').", + ) + netbox_bay_name_pattern = models.CharField( + max_length=255, + help_text="Regex (Python re.fullmatch) on the chassis-level empty module bay name " + "where the carrier should be installed (e.g. '^CMA$' or '^Carrier \\d+$'). " + "All matching empty bays will be offered as install targets.", + ) + carrier_module_type = models.ForeignKey( + ModuleType, + on_delete=models.PROTECT, + related_name="librenms_carrier_install_rules", + help_text="The NetBox ModuleType to suggest installing into the matching empty bay.", + ) + description = models.TextField( + blank=True, + help_text="Optional notes about this rule.", + ) + + @functools.cached_property + def _compiled_device_type_pattern(self): + if not self.device_type_pattern: + return None + try: + return re.compile(self.device_type_pattern) + except re.error: + return None + + @functools.cached_property + def _compiled_child_name_pattern(self): + if not self.librenms_child_name_pattern: + return None + try: + return re.compile(self.librenms_child_name_pattern) + except re.error: + return None + + @functools.cached_property + def _compiled_bay_name_pattern(self): + if not self.netbox_bay_name_pattern: + return None + try: + return re.compile(self.netbox_bay_name_pattern) + except re.error: + return None + + def clean(self): + super().clean() + # Invalidate cached compiled patterns so they recompute from new values. + self.__dict__.pop("_compiled_device_type_pattern", None) + self.__dict__.pop("_compiled_child_name_pattern", None) + self.__dict__.pop("_compiled_bay_name_pattern", None) + + self.device_type_pattern = (self.device_type_pattern or "").strip() + self.librenms_child_class = (self.librenms_child_class or "").strip() + self.librenms_child_name_pattern = (self.librenms_child_name_pattern or "").strip() + self.netbox_bay_name_pattern = (self.netbox_bay_name_pattern or "").strip() + + if not self.librenms_child_class: + raise ValidationError({"librenms_child_class": "This field is required."}) + if not self.librenms_child_name_pattern: + raise ValidationError({"librenms_child_name_pattern": "This field is required."}) + if not self.netbox_bay_name_pattern: + raise ValidationError({"netbox_bay_name_pattern": "This field is required."}) + + for field, value in ( + ("device_type_pattern", self.device_type_pattern), + ("librenms_child_name_pattern", self.librenms_child_name_pattern), + ("netbox_bay_name_pattern", self.netbox_bay_name_pattern), + ): + if not value: + continue + try: + re.compile(value) + except re.error as e: + raise ValidationError({field: f"Invalid regex: {e}"}) + + def get_absolute_url(self): + return reverse( + "plugins:netbox_librenms_plugin:carrierautoinstallrule_detail", + args=[self.pk], + ) + + class Meta: + """Meta options for CarrierAutoInstallRule.""" + + ordering = ["manufacturer__name", "librenms_child_class", "librenms_child_name_pattern"] + # See ModuleBayMapping.Meta.constraints for the full rationale: the + # nullable manufacturer FK forces a pair of conditional + # UniqueConstraints because PostgreSQL 12-14 (still supported by + # NetBox 4.2) does not honour ``nulls_distinct=False``. Issue #71. + constraints = [ + models.UniqueConstraint( + fields=[ + "manufacturer", + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + ], + condition=models.Q(manufacturer__isnull=False), + name="unique_carrier_auto_install_rule", + ), + models.UniqueConstraint( + fields=[ + "device_type_pattern", + "librenms_child_class", + "librenms_child_name_pattern", + "netbox_bay_name_pattern", + ], + condition=models.Q(manufacturer__isnull=True), + name="unique_carrier_auto_install_rule_global", + ), + ] + + def __str__(self): + scope = self.manufacturer.name if self.manufacturer else "*" + if self.device_type_pattern: + scope = f"{scope}/{self.device_type_pattern}" + return ( + f"{scope}: {self.librenms_child_class} '{self.librenms_child_name_pattern}'" + f" -> install {self.carrier_module_type} into '{self.netbox_bay_name_pattern}'" + ) + + def to_yaml(self): + data = { + "manufacturer": self.manufacturer.name if self.manufacturer else "", + "device_type_pattern": self.device_type_pattern, + "librenms_child_class": self.librenms_child_class, + "librenms_child_name_pattern": self.librenms_child_name_pattern, + "netbox_bay_name_pattern": self.netbox_bay_name_pattern, + "carrier_module_type": str(self.carrier_module_type), + "description": self.description, + } + return yaml.dump(data, sort_keys=False) diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index a08e62740..7a81a395a 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -1,19 +1,44 @@ from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem -from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN +from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN menu = PluginMenu( label="LibreNMS", icon_class="mdi mdi-network", groups=( ( - "Settings", + "Import", ( PluginMenuItem( - link="plugins:netbox_librenms_plugin:settings", - link_text="Plugin Settings", + link="plugins:netbox_librenms_plugin:librenms_import", + link_text="LibreNMS Import", + permissions=[PERM_VIEW_PLUGIN], + ), + ), + ), + ( + "Status Check", + ( + PluginMenuItem( + link="plugins:netbox_librenms_plugin:site_location_sync", + link_text="Site & Location Sync", permissions=[PERM_VIEW_PLUGIN], ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:device_status_list", + link_text="Device Status", + permissions=[PERM_VIEW_PLUGIN], + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:vm_status_list", + link_text="VM Status", + permissions=[PERM_VIEW_PLUGIN], + ), + ), + ), + ( + "Mappings", + ( PluginMenuItem( link="plugins:netbox_librenms_plugin:interfacetypemapping_list", link_text="Interface Mappings", @@ -23,42 +48,157 @@ link="plugins:netbox_librenms_plugin:interfacetypemapping_add", title="Add", icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], ), PluginMenuButton( link="plugins:netbox_librenms_plugin:interfacetypemapping_bulk_import", title="Import", icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], ), ), ), - ), - ), - ( - "Import", - ( PluginMenuItem( - link="plugins:netbox_librenms_plugin:librenms_import", - link_text="LibreNMS Import", + link="plugins:netbox_librenms_plugin:devicetypemapping_list", + link_text="Device Type Mappings", permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), ), - ), - ), - ( - "Status Check", - ( PluginMenuItem( - link="plugins:netbox_librenms_plugin:site_location_sync", - link_text="Site & Location Sync", + link="plugins:netbox_librenms_plugin:moduletypemapping_list", + link_text="Module Type Mappings", permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), ), PluginMenuItem( - link="plugins:netbox_librenms_plugin:device_status_list", - link_text="Device Status", + link="plugins:netbox_librenms_plugin:modulebaymapping_list", + link_text="Module Bay Mappings", permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), ), PluginMenuItem( - link="plugins:netbox_librenms_plugin:vm_status_list", - link_text="VM Status", + link="plugins:netbox_librenms_plugin:platformmapping_list", + link_text="Platform Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:platformmapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:platformmapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:inventoryignorerule_list", + link_text="Inventory Ignore Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:inventoryignorerule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:inventoryignorerule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:normalizationrule_list", + link_text="Normalization Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:carrierautoinstallrule_list", + link_text="Carrier Auto-Install Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:carrierautoinstallrule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:carrierautoinstallrule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), + ), + ), + ), + ( + "Settings", + ( + PluginMenuItem( + link="plugins:netbox_librenms_plugin:settings", + link_text="Plugin Settings", permissions=[PERM_VIEW_PLUGIN], ), ), diff --git a/netbox_librenms_plugin/signals.py b/netbox_librenms_plugin/signals.py new file mode 100644 index 000000000..e1ffd07eb --- /dev/null +++ b/netbox_librenms_plugin/signals.py @@ -0,0 +1,7 @@ +"""Plugin extension signals β€” external plugins may subscribe to alter behavior.""" + +from django.dispatch import Signal + +# Args: device, module, names (list[str]). Receivers may return a rewritten list, +# or None to leave unchanged. Last non-None return wins. +predict_module_interface_names = Signal() diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index 68fb8d264..edfda206a 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js @@ -234,6 +234,82 @@ return cookieValue; } + // ============================================ + // ERROR TOAST DISPLAY + // ============================================ + + /** + * Build and show a Bootstrap toast for an HTMX error response. + * Appends to NetBox's #django-messages container so it uses the same + * styling and stacking as Django messages. Falls back to console.error + * if Bootstrap or the container is unavailable. + * + * Accepts either an XHR (from htmx:responseError) or a plain string message + * (from the librenmsError HX-Trigger event dispatched by the server). + * + * @param {XMLHttpRequest|string|null} source + */ + function showErrorToast(source) { + if (!source) { + return; + } + const isXhr = typeof source === 'object' && 'responseText' in source; + const container = document.getElementById('django-messages'); + if (!container || typeof bootstrap === 'undefined' || !bootstrap.Toast) { + if (isXhr) { + console.error('LibreNMS plugin: server error', source.status, source.responseText); + } else { + console.error('LibreNMS plugin: server error', source); + } + return; + } + + // Truncate very long error bodies (some Django validation traces are huge). + let raw; + if (isXhr) { + raw = (source.responseText || '').trim(); + if (!raw) { + raw = `Request failed with status ${source.status}`; + } + } else { + raw = String(source).trim() || 'Server error'; + } + const MAX = 600; + if (raw.length > MAX) { + raw = raw.slice(0, MAX) + '\u2026'; + } + + const toast = document.createElement('div'); + toast.className = 'toast toast-dark border-0 shadow-sm'; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + toast.setAttribute('data-bs-delay', '12000'); + + const header = document.createElement('div'); + header.className = 'toast-header text-bg-danger'; + const icon = document.createElement('i'); + icon.className = 'mdi mdi-alert-circle me-1'; + header.appendChild(icon); + header.appendChild(document.createTextNode(' Error')); + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn-close me-0 m-auto'; + closeBtn.setAttribute('data-bs-dismiss', 'toast'); + closeBtn.setAttribute('aria-label', 'Close'); + header.appendChild(closeBtn); + + const body = document.createElement('div'); + body.className = 'toast-body'; + // Use textContent to keep server response untrusted-safe (no HTML injection). + body.textContent = raw; + + toast.appendChild(header); + toast.appendChild(body); + container.appendChild(toast); + bootstrap.Toast.getOrCreateInstance(toast).show(); + } + // ============================================ // USER PREFERENCE PERSISTENCE // ============================================ @@ -273,8 +349,8 @@ * Persists toggle state to user preferences on change. */ function initializeTogglePrefs() { - const sysname = document.getElementById('use-sysname-toggle'); - const strip = document.getElementById('strip-domain-toggle'); + const sysname = document.getElementById('use-sysname-toggle-cb'); + const strip = document.getElementById('strip-domain-toggle-cb'); if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); }); if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); }); } @@ -1035,7 +1111,14 @@ if (event.target === bulkImportBtn && pendingRowImport) { restoreSelectionState(pendingRowImport.previousSelections); pendingRowImport = null; + return; } + // Fallback for genuine 5xx / unexpected 4xx responses that bypass + // the server-side _htmx_error_response helper (which returns 200 + + // an OOB toast for expected validation errors). + try { + showErrorToast(event.detail && event.detail.xhr); + } catch (_) { /* never let toast-rendering break HTMX flow */ } }); // SessionStorage management for device roles @@ -1089,9 +1172,13 @@ return; } - if (modalContent && modalContent.innerHTML.trim().length === 0 && event.detail.xhr) { - modalContent.innerHTML = event.detail.xhr.responseText; - } + // NOTE: Do NOT fall back to `modalContent.innerHTML = xhr.responseText` + // when the swap leaves the modal empty. That would inject the response + // without going through htmx.process(), so any inner forms with hx-post + // would not be HTMX-instrumented and would submit natively, navigating + // the browser to the raw response (e.g. a 400 with a plain validation + // error message). HTMX has already performed the swap; if the response + // body was empty, leaving the modal empty is the correct behaviour. // Initialize Bootstrap tooltips inside the freshly-swapped modal content if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) { @@ -1112,10 +1199,14 @@ const dismissTrigger = event.target.closest('[data-bs-dismiss="modal"]'); if (dismissTrigger) { - event.preventDefault(); - - // Check if it's in the HTMX modal - if (modalElement.contains(dismissTrigger)) { + // Only handle dismiss triggers whose nearest .modal ancestor IS + // the outer HTMX modal. Buttons inside nested modals (e.g. the + // Promote-to-host modal rendered inside #htmx-modal-content) + // must be left for Bootstrap's own dismiss handler so they + // close the inner modal, not the outer one. + const nearestModal = dismissTrigger.closest('.modal'); + if (nearestModal === modalElement) { + event.preventDefault(); hideModal(modalElement, fallbackBackdropRef); } } diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js index 1521b3d29..9a9b0a020 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js @@ -102,8 +102,7 @@ function hideModal(el) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); el.removeAttribute('aria-modal'); - const backdrop = document.querySelector('.modal-backdrop'); - if (backdrop) backdrop.remove(); + document.querySelectorAll('.modal-backdrop').forEach((backdrop) => backdrop.remove()); document.body.classList.remove('modal-open'); document.body.style.removeProperty('padding-right'); document.body.style.removeProperty('overflow'); @@ -125,6 +124,25 @@ function getCookie(name) { return cookieValue; } +/** + * Extract a human-readable error message from a non-2xx fetch Response. + * Attempts JSON parse first, checking error/message/detail fields. + * Falls back to raw response text. Truncates to 300 characters. + * @param {Response} response + * @returns {Promise} + */ +function fetchErrorMessage(response) { + return response.text().then(t => { + const ct = (response.headers.get('Content-Type') || '').toLowerCase(); + let msg = t || `HTTP ${response.status}`; + if (ct.includes('application/json')) { + try { const d = JSON.parse(t); msg = d.error || d.message || d.detail || msg; } catch (_) {} + } + if (msg.length > 300) msg = msg.slice(0, 300) + '...'; + return msg; + }); +} + /** * Extract device/VM ID and type from current URL pathname. * Supports multiple URL patterns: @@ -248,10 +266,15 @@ function initializeCountdowns() { if (window.vlanCountdownInterval) { clearInterval(window.vlanCountdownInterval); } + if (window.moduleCountdownInterval) { + clearInterval(window.moduleCountdownInterval); + } + window.interfaceCountdownInterval = initializeCountdown("countdown-timer"); window.cableCountdownInterval = initializeCountdown("cable-countdown-timer"); window.ipCountdownInterval = initializeCountdown("ip-countdown-timer"); window.vlanCountdownInterval = initializeCountdown("vlan-countdown-timer"); + window.moduleCountdownInterval = initializeCountdown("module-countdown-timer"); } // ============================================ @@ -311,6 +334,7 @@ function initializeCheckboxes() { initializeTableCheckboxes('librenms-ipaddress-table'); initializeTableCheckboxes('librenms-vlan-table'); initializeTableCheckboxes('librenms-port-vlan-table'); + initializeTableCheckboxes('librenms-module-table'); } // ============================================ @@ -325,6 +349,7 @@ function initializeVCMemberSelect() { setTimeout(() => { const interfaceTable = document.getElementById('librenms-interface-table'); const cableTable = document.getElementById('librenms-cable-table-vc'); + const moduleTable = document.getElementById('librenms-module-table'); if (interfaceTable) { // Only target VC member selects, exclude VLAN group selects @@ -350,6 +375,23 @@ function initializeVCMemberSelect() { } }); } + + if (moduleTable) { + const moduleSelects = moduleTable.querySelectorAll('.vc-member-select'); + moduleSelects.forEach(select => { + if (select.tomselect && !select.dataset.moduleSelectInitialized) { + select.dataset.moduleSelectInitialized = 'true'; + select.tomselect.on('change', function (value) { + handleModuleChange(select, value); + }); + } else if (!select.tomselect && !select.dataset.moduleSelectInitialized) { + select.dataset.moduleSelectInitialized = 'true'; + select.addEventListener('change', function () { + handleModuleChange(select, this.value); + }); + } + }); + } }, TOMSELECT_INIT_DELAY_MS); } @@ -563,7 +605,7 @@ function verifyVlanInGroup(select, deviceId, vid, vlanType, groupId) { }) .then(response => { if (!response.ok) { - return response.text().then(t => { throw new Error(t || `HTTP ${response.status}`); }); + return fetchErrorMessage(response).then(msg => { throw new Error(msg); }); } return response.json(); }) @@ -745,7 +787,7 @@ function initializeVlanModalSave() { }) }).then(response => { if (!response.ok) { - return response.text().then(t => { throw new Error(`HTTP ${response.status}: ${t}`); }); + return fetchErrorMessage(response).then(msg => { throw new Error(`HTTP ${response.status}: ${msg}`); }); } // Apply DOM mutations only after the server has persisted the overrides applyButtonUpdates(); @@ -819,7 +861,7 @@ function verifyVlanSyncGroup(select, vid, vlanName, groupId) { }) .then(response => { if (!response.ok) { - throw new Error('HTTP ' + response.status); + return fetchErrorMessage(response).then(msg => { throw new Error(`HTTP ${response.status}: ${msg}`); }); } return response.json(); }) @@ -889,7 +931,7 @@ function handleVRFChange(select, value) { }) .then(response => { if (!response.ok) { - return response.text().then(t => { throw new Error(t); }); + return fetchErrorMessage(response).then(msg => { throw new Error(msg); }); } return response.json(); }) @@ -931,9 +973,7 @@ function handleInterfaceChange(select, value) { }) .then(response => { if (!response.ok) { - return response.text().then(text => { - throw new Error(`Server error ${response.status}: ${text}`); - }); + return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); }); } return response.json(); }) @@ -978,9 +1018,7 @@ function handleCableChange(select, value) { }) .then(response => { if (!response.ok) { - return response.text().then(text => { - throw new Error(`Server error ${response.status}: ${text}`); - }); + return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); }); } return response.json(); }) @@ -1001,6 +1039,86 @@ function handleCableChange(select, value) { }); } +/** + * Handle VC member selection change for module verification. + * Fetches recalculated matching status for one module row and updates cells inline. + * + * @param {HTMLSelectElement} select - VC member dropdown for a module row + * @param {string} value - Selected NetBox device ID + */ +function handleModuleChange(select, value) { + const row = document.querySelector(`tr[data-ent-index="${select.dataset.rowId}"]`); + const rowDepth = row?.dataset?.depth || 0; + + // Abort any in-flight verify for this select so a slower earlier response + // can't clobber a faster later one when the user changes the dropdown rapidly. + if (select._moduleVerifyController) { + select._moduleVerifyController.abort(); + } + const controller = new AbortController(); + select._moduleVerifyController = controller; + + fetch('/plugins/librenms_plugin/verify-module/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value + }, + body: JSON.stringify({ + device_id: value, + ent_physical_index: select.dataset.module, + depth: rowDepth, + server_key: document.querySelector('input[name="server_key"]')?.value || null + }), + signal: controller.signal + }) + .then(response => { + if (!response.ok) { + return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); }); + } + return response.json(); + }) + .then(data => { + if (!row || data.status !== 'success' || !data.formatted_row) return; + + const formattedRow = data.formatted_row; + const deviceSelCell = row.querySelector('td[data-col="device_selection"]'); + if (deviceSelCell) { + deviceSelCell.innerHTML = formattedRow.device_selection || ''; + } + row.querySelector('td[data-col="name"]').innerHTML = formattedRow.name; + row.querySelector('td[data-col="model"]').innerHTML = formattedRow.model; + row.querySelector('td[data-col="serial"]').innerHTML = formattedRow.serial; + row.querySelector('td[data-col="description"]').innerHTML = formattedRow.description; + row.querySelector('td[data-col="item_class"]').innerHTML = formattedRow.item_class; + // Replace each cell content if present. Defensive null-checks keep this + // resilient if the row markup ever drops one of these data-col cells. + const cellMap = { + module_bay: formattedRow.module_bay, + module_type: formattedRow.module_type, + status: formattedRow.status, + actions: formattedRow.actions, + }; + for (const [col, html] of Object.entries(cellMap)) { + const cell = row.querySelector(`td[data-col="${col}"]`); + if (cell) { + cell.innerHTML = html; + } else { + console.warn(`Module row missing data-col="${col}" cell β€” skipping update`); + } + } + + // Re-bind listeners because row controls (select/buttons/forms) were replaced. + initializeVCMemberSelect(); + initializeModuleReplaceButtons(); + initializeVCReportButtons(); + }) + .catch(error => { + if (error.name === 'AbortError') return; + console.error('Error verifying module:', error.message); + }); +} + /** * Initialize bulk VC member assignment functionality. * Applies selected VC member to all checked interfaces. @@ -1299,7 +1417,7 @@ function updateInterfaceNameField() { // Persist to user preferences via API const savePrefUrl = this.closest('[data-save-pref-url]')?.dataset.savePrefUrl; if (savePrefUrl) { - const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken'); + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value; if (csrfToken) { fetch(savePrefUrl, { method: 'POST', @@ -1425,6 +1543,11 @@ function deleteSelectedInterfaces(selectedCheckboxes) { } }) .then(response => { + if (!response.ok) { + return fetchErrorMessage(response).then(msg => { + throw new Error(`HTTP ${response.status} ${response.statusText}: ${msg}`); + }); + } return response.json(); }) .then(data => { @@ -1484,15 +1607,18 @@ function initializeSyncFormSpinners() { button.dataset.spinnerInitialized = 'true'; button.addEventListener('htmx:beforeRequest', function () { - const originalText = button.textContent.trim(); - button.dataset.originalText = originalText; + button.dataset.originalHtml = button.innerHTML; button.disabled = true; - button.innerHTML = '' + originalText; + const label = button.textContent.trim(); + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm me-2'; + button.textContent = label; + button.insertBefore(spinner, button.firstChild); }); button.addEventListener('htmx:afterRequest', function () { button.disabled = false; - button.innerHTML = button.dataset.originalText || button.textContent; + button.innerHTML = button.dataset.originalHtml; }); }); } @@ -1520,6 +1646,16 @@ function handleInstallSelectedSubmit() { hidden.value = cb.value; hidden.dataset.injectedSelect = '1'; form.appendChild(hidden); + + const selectedDevice = table.querySelector(`#device_selection_${cb.value}`); + if (selectedDevice) { + const hiddenDevice = document.createElement('input'); + hiddenDevice.type = 'hidden'; + hiddenDevice.name = `device_selection_${cb.value}`; + hiddenDevice.value = selectedDevice.value; + hiddenDevice.dataset.injectedSelect = '1'; + form.appendChild(hiddenDevice); + } }); } @@ -1559,11 +1695,13 @@ function initializeModuleReplaceButtons() { const moduleId = this.dataset.moduleId; const entIndex = this.dataset.entIndex; const serverKey = this.dataset.serverKey; + const selectedDeviceId = this.dataset.selectedDeviceId; const params = new URLSearchParams({ module_id: moduleId, ent_index: entIndex, server_key: serverKey, + selected_device_id: selectedDeviceId, }); // Show shared HTMX modal with loading state @@ -1571,7 +1709,7 @@ function initializeModuleReplaceButtons() { if (modalContent) { modalContent.innerHTML = '' + ' diff --git a/netbox_librenms_plugin/tests/mock_librenms_server.py b/netbox_librenms_plugin/tests/mock_librenms_server.py index 83a9258db..7b07d6d52 100644 --- a/netbox_librenms_plugin/tests/mock_librenms_server.py +++ b/netbox_librenms_plugin/tests/mock_librenms_server.py @@ -106,10 +106,6 @@ def register(self, path: str, body, status: int = 200, method: str | None = None If *method* is given the route is stored as ``"METHOD /path"`` and only matches requests using that HTTP verb. Omit *method* (or pass ``None``) to match any verb on that path. - - *body* may be a ``dict`` (serialised to JSON) or a callable. When a - callable is provided it is stored directly and invoked by the handler on - each matching request; the *status* argument is ignored in that case. """ key = f"{method} {path}" if method else path if callable(body): diff --git a/netbox_librenms_plugin/tests/test_background_jobs.py b/netbox_librenms_plugin/tests/test_background_jobs.py index f1d1bd227..42adb15bb 100644 --- a/netbox_librenms_plugin/tests/test_background_jobs.py +++ b/netbox_librenms_plugin/tests/test_background_jobs.py @@ -132,6 +132,7 @@ def test_run_with_vc_detection_enabled(self, mock_process, mock_api_class): mock_api = MagicMock() mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" mock_api_class.return_value = mock_api mock_process.return_value = [] @@ -155,6 +156,7 @@ def test_run_with_clear_cache(self, mock_process, mock_api_class): mock_api = MagicMock() mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" mock_api_class.return_value = mock_api mock_process.return_value = [] @@ -178,6 +180,7 @@ def test_run_with_show_disabled(self, mock_process, mock_api_class): mock_api = MagicMock() mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" mock_api_class.return_value = mock_api mock_process.return_value = [] @@ -201,6 +204,7 @@ def test_run_with_exclude_existing(self, mock_process, mock_api_class): mock_api = MagicMock() mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" mock_api_class.return_value = mock_api mock_process.return_value = [] @@ -244,6 +248,29 @@ def test_run_with_custom_server_key(self, mock_process, mock_api_class): # Verify server_key stored in job data assert job.job.data["server_key"] == "secondary" + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_filter_job_stores_server_key(self, mock_process, mock_api_class): + """Job stores resolved api.server_key, not raw input parameter.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api.server_key = "resolved-default" + mock_api_class.return_value = mock_api + mock_process.return_value = [{"device_id": 1, "hostname": "test1"}] + + job = create_mock_job_runner(FilterDevicesJob) + job.run( + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=False, + server_key=None, + ) + + assert job.job.data["server_key"] == "resolved-default" + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") @patch("netbox_librenms_plugin.import_utils.process_device_filters") def test_run_stores_job_data_correctly(self, mock_process, mock_api_class): @@ -678,6 +705,32 @@ def test_job_meta_name(self): assert ImportDevicesJob.Meta.name == "LibreNMS Device Import" + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_import_job_stores_server_key(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Import job stores resolved api.server_key in job metadata and forwards it to bulk_import_devices_shared.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api = MagicMock() + mock_api.server_key = "resolved-default" + mock_api_class.return_value = mock_api + mock_bulk_devices.return_value = { + "success": [], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + mock_bulk_vms.return_value = {"success": [], "failed": [], "skipped": []} + + job = create_mock_job_runner(ImportDevicesJob) + job.run(device_ids=[1], vm_imports={}, server_key=None) + + assert job.job.data["server_key"] == "resolved-default" + mock_bulk_devices.assert_called_once() + call_kwargs = mock_bulk_devices.call_args[1] + assert call_kwargs.get("server_key") == "resolved-default" + class TestLoadJobResults: """Test loading results from completed background jobs.""" @@ -926,42 +979,155 @@ def test_load_partial_cache_returns_available(self, mock_job_class, mock_get_key class TestGracefulFallback: """Test graceful fallback when RQ workers unavailable.""" + def _make_view_with_request(self, superuser=True, query_params=None): + """Helper to set up a LibreNMSImportView with a mock request.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = object.__new__(LibreNMSImportView) + request = MagicMock() + request.user.is_superuser = superuser + request.user.username = "testuser" + request.GET = dict(query_params or {}) + view.request = request + return view, request + @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") def test_no_workers_triggers_synchronous_processing(self, mock_get_workers): - """No RQ workers triggers synchronous fallback.""" - mock_get_workers.return_value = 0 + """No RQ workers: view falls back to synchronous processing, FilterDevicesJob.enqueue not called.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView - # This test verifies the condition check, not full request handling - from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + mock_get_workers.return_value = 0 - workers = get_workers_for_queue("default") - assert workers == 0 + view, request = self._make_view_with_request( + superuser=True, + query_params={"apply_filters": "1", "librenms_location": "DC1"}, + ) + mock_api = MagicMock() + mock_api.server_key = "default" - # When workers == 0, the code path skips job enqueuing - # and falls through to synchronous get_queryset processing + with ( + patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)), + patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings, + patch("netbox_librenms_plugin.views.imports.list.get_user_pref", return_value=None), + patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache, + patch("netbox_librenms_plugin.import_utils.get_cache_metadata_key", return_value="meta_key"), + patch("netbox_librenms_plugin.import_utils.get_device_count_for_filters", return_value=5), + patch("netbox_librenms_plugin.views.imports.list.render") as mock_render, + patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"), + patch("netbox_librenms_plugin.views.imports.list.get_active_cached_searches", return_value=[]), + patch("netbox_librenms_plugin.jobs.FilterDevicesJob") as mock_job_cls, + patch("netbox_librenms_plugin.views.imports.list.messages"), + patch("netbox_librenms_plugin.views.imports.list.process_device_filters") as mock_pdf, + ): + mock_settings.objects.first.return_value = None + mock_settings.objects.get_or_create.return_value = (None, False) + mock_cache.get.return_value = None + mock_render.return_value = MagicMock() + + mock_form_cls = MagicMock() + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = {"enable_vc_detection": False, "clear_cache": False, "use_background_job": True} + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls + mock_pdf.return_value = ([], False) + + with patch.object(view, "get_server_info", return_value={}): + view.get(request) + + # Workers == 0 means synchronous fallback β€” enqueue must not be called + mock_job_cls.enqueue.assert_not_called() + mock_render.assert_called_once() + mock_pdf.assert_called_once() @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") def test_workers_available_allows_background_job(self, mock_get_workers): - """Available workers allow background job enqueue.""" - mock_get_workers.return_value = 2 + """Available workers: view enqueues FilterDevicesJob and returns JSON response.""" + from django.http import JsonResponse - from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView - workers = get_workers_for_queue("default") - assert workers > 0 - # When workers > 0, the code path proceeds to FilterDevicesJob.enqueue() + mock_get_workers.return_value = 2 + + view, request = self._make_view_with_request( + superuser=True, + query_params={"apply_filters": "1", "librenms_location": "DC1"}, + ) + mock_api = MagicMock() + mock_api.server_key = "server-2" + + with ( + patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)), + patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings, + patch("netbox_librenms_plugin.views.imports.list.get_user_pref", return_value=None), + patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache, + patch("netbox_librenms_plugin.import_utils.get_cache_metadata_key", return_value="meta_key"), + patch("netbox_librenms_plugin.import_utils.get_device_count_for_filters", return_value=10), + patch("netbox_librenms_plugin.jobs.FilterDevicesJob") as mock_job_cls, + ): + mock_settings.objects.first.return_value = None + mock_cache.get.return_value = None + mock_job = MagicMock() + mock_job.pk = 99 + mock_job.job_id = "uuid-99" + mock_job_cls.enqueue.return_value = mock_job + + mock_form_cls = MagicMock() + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = {"enable_vc_detection": False, "clear_cache": False, "use_background_job": True} + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls + + result = view.get(request) + + # Workers > 0 means background job should have been enqueued + mock_job_cls.enqueue.assert_called_once() + # Verify the non-default server_key was forwarded to the background job + assert mock_job_cls.enqueue.call_args.kwargs["server_key"] == "server-2" + assert isinstance(result, JsonResponse) @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") - @patch("netbox_librenms_plugin.views.imports.list.logger") - def test_fallback_logs_warning(self, mock_logger, mock_get_workers): - """Warning logged when falling back (checked via worker count).""" - mock_get_workers.return_value = 0 + @patch("netbox_librenms_plugin.views.imports.list.messages") + def test_fallback_logs_warning(self, mock_messages, mock_get_workers): + """No workers: view logs a warning message when falling back to synchronous mode.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView - # Verify the function returns 0 workers which would trigger fallback - from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + mock_get_workers.return_value = 0 - workers = get_workers_for_queue("default") - assert workers == 0 + view, request = self._make_view_with_request( + superuser=True, + query_params={"apply_filters": "1", "librenms_location": "DC1"}, + ) + mock_api = MagicMock() + mock_api.server_key = "default" - # The view would log a warning when it detects no workers and falls back - # This test verifies the condition that triggers the fallback path + with ( + patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)), + patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings, + patch("netbox_librenms_plugin.views.imports.list.get_user_pref", return_value=None), + patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache, + patch("netbox_librenms_plugin.import_utils.get_cache_metadata_key", return_value="meta_key"), + patch("netbox_librenms_plugin.import_utils.get_device_count_for_filters", return_value=3), + patch("netbox_librenms_plugin.views.imports.list.render") as mock_render, + patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"), + patch("netbox_librenms_plugin.views.imports.list.get_active_cached_searches", return_value=[]), + patch("netbox_librenms_plugin.jobs.FilterDevicesJob"), + ): + mock_settings.objects.first.return_value = None + mock_settings.objects.get_or_create.return_value = (None, False) + mock_cache.get.return_value = None + mock_render.return_value = MagicMock() + + mock_form_cls = MagicMock() + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = {"enable_vc_detection": False, "clear_cache": False, "use_background_job": True} + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls + + with patch.object(view, "get_server_info", return_value={}): + view.get(request) + + # A warning should be emitted when falling back to sync due to no workers + mock_messages.warning.assert_called_once() diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index 50e8288eb..36da3e5f8 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -56,7 +56,8 @@ def test_validation_error_returns_400(self): device.full_clean.side_effect = ValidationError({"name": ["This field is required."]}) response = _save_device(device) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" def test_integrity_error_returns_409(self): from django.db import IntegrityError @@ -68,7 +69,8 @@ def test_integrity_error_returns_409(self): device.save.side_effect = IntegrityError("duplicate key") response = _save_device(device) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" def test_success_returns_none(self): from netbox_librenms_plugin.views.imports.actions import _save_device @@ -420,8 +422,12 @@ def test_get_validated_device_returns_data_when_found(self): "netbox_librenms_plugin.views.imports.actions.validate_device_for_import", return_value={"status": "importable"}, ): - request = _make_request() - result_device, validation, selections = view.get_validated_device_with_selections(1, request) + with patch("netbox_librenms_plugin.views.imports.actions.cache") as mock_cache: + mock_cache.get.return_value = None + request = _make_request() + result_device, validation, selections = view.get_validated_device_with_selections( + 1, request + ) assert result_device is libre_device assert validation is not None @@ -665,11 +671,13 @@ def _make_view(self): view._librenms_api = _make_api() return view - def test_device_not_found_returns_404(self): + def test_device_not_found_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, {})): result = view.post(MagicMock(), device_id=1) - assert result.status_code == 404 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Device not found" in result.content @patch("netbox_librenms_plugin.views.imports.actions.render") def test_device_found_renders_row(self, mock_render): @@ -699,11 +707,13 @@ def _make_view(self): view._librenms_api = _make_api() return view - def test_device_not_found_returns_404(self): + def test_device_not_found_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, {})): result = view.post(MagicMock(), device_id=1) - assert result.status_code == 404 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Device not found" in result.content class TestDeviceRackUpdateView: @@ -716,11 +726,13 @@ def _make_view(self): view._librenms_api = _make_api() return view - def test_device_not_found_returns_404(self): + def test_device_not_found_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, {})): result = view.post(MagicMock(), device_id=1) - assert result.status_code == 404 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Device not found" in result.content class TestDeviceConflictActionView: @@ -740,21 +752,25 @@ def test_no_permission_returns_error(self): result = view.post(MagicMock(), device_id=1) assert result is error_resp - def test_missing_action_returns_400(self): + def test_missing_action_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "require_write_permission", return_value=None): request = _make_request(post={"existing_device_id": "1"}) result = view.post(request, device_id=1) - assert result.status_code == 400 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Missing action or existing_device_id" in result.content - def test_missing_existing_device_id_returns_400(self): + def test_missing_existing_device_id_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "require_write_permission", return_value=None): request = _make_request(post={"action": "link"}) result = view.post(request, device_id=1) - assert result.status_code == 400 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Missing action or existing_device_id" in result.content - def test_vm_with_unsupported_action_returns_400(self): + def test_vm_with_unsupported_action_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "require_write_permission", return_value=None): request = _make_request( @@ -765,9 +781,11 @@ def test_vm_with_unsupported_action_returns_400(self): } ) result = view.post(request, device_id=1) - assert result.status_code == 400 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"is not supported for virtual machines" in result.content - def test_existing_device_not_found_returns_404(self): + def test_existing_device_not_found_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "require_write_permission", return_value=None): with patch("dcim.models.Device") as MockDevice: @@ -777,9 +795,11 @@ def test_existing_device_not_found_returns_404(self): request = _make_request(post={"action": "link", "existing_device_id": "abc"}) result = view.post(request, device_id=1) - assert result.status_code == 404 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Existing device not found" in result.content - def test_unknown_action_returns_400(self): + def test_unknown_action_renders_htmx_error_toast(self): view = self._make_view() with patch.object(view, "require_write_permission", return_value=None): with patch("dcim.models.Device") as MockDevice: @@ -802,7 +822,9 @@ def test_unknown_action_returns_400(self): ) result = view.post(request, device_id=1) - assert result.status_code == 400 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" + assert b"Unknown action: unknown_action" in result.content class TestSaveUserPrefView: @@ -943,7 +965,10 @@ def test_no_explicit_vc_detection_still_returns_true(self): def test_enable_vc_detection_from_post(self): view = self._make_view() request = _make_request(post={"enable_vc_detection": "on"}) - assert view._should_enable_vc_detection(1, request) is True + with patch("netbox_librenms_plugin.views.imports.actions.cache") as mock_cache: + mock_cache.get.return_value = None + result = view._should_enable_vc_detection(1, request) + assert result is True class TestBuildSyncInfoNoPlatform: @@ -1094,7 +1119,7 @@ def _make_view(self): view.request = MagicMock() return view - def test_non_migrate_action_for_vm_returns_400(self): + def test_non_migrate_action_for_vm_renders_htmx_error_toast(self): """Lines 995-999: VM + non-migrate action = 400.""" view = self._make_view() request = _make_request( @@ -1108,17 +1133,21 @@ def test_non_migrate_action_for_vm_returns_400(self): with patch.object(view, "require_all_permissions", return_value=None): response = view.post(request, device_id=1) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"is not supported for virtual machines" in response.content - def test_missing_action_returns_400(self): - """Line 989-990: missing action returns 400.""" + def test_missing_action_renders_htmx_error_toast(self): + """Line 989-990: missing action renders htmx error toast (200).""" view = self._make_view() request = _make_request(post={"existing_device_id": "1"}) # No action with patch.object(view, "require_all_permissions", return_value=None): response = view.post(request, device_id=1) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Missing action or existing_device_id" in response.content def test_server_key_override_creates_new_api(self): """Line 987: POST server_key creates new LibreNMSAPI.""" @@ -1153,7 +1182,7 @@ class TestDeviceRoleClusterRackViews: """Tests for DeviceRoleUpdateView, DeviceClusterUpdateView, DeviceRackUpdateView.""" def test_device_role_update_not_found(self): - """DeviceRoleUpdateView returns 404 when device not found.""" + """DeviceRoleUpdateView renders htmx error toast (200) when device not found.""" from netbox_librenms_plugin.views.imports.actions import DeviceRoleUpdateView view = object.__new__(DeviceRoleUpdateView) @@ -1165,10 +1194,12 @@ def test_device_role_update_not_found(self): with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, None)): response = view.post(request, device_id=1) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device not found" in response.content def test_device_cluster_update_not_found(self): - """DeviceClusterUpdateView returns 404 when device not found.""" + """DeviceClusterUpdateView renders htmx error toast (200) when device not found.""" from netbox_librenms_plugin.views.imports.actions import DeviceClusterUpdateView view = object.__new__(DeviceClusterUpdateView) @@ -1180,10 +1211,12 @@ def test_device_cluster_update_not_found(self): with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, None)): response = view.post(request, device_id=1) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device not found" in response.content def test_device_rack_update_not_found(self): - """DeviceRackUpdateView returns 404 when device not found.""" + """DeviceRackUpdateView renders htmx error toast (200) when device not found.""" from netbox_librenms_plugin.views.imports.actions import DeviceRackUpdateView view = object.__new__(DeviceRackUpdateView) @@ -1195,7 +1228,9 @@ def test_device_rack_update_not_found(self): with patch.object(view, "get_validated_device_with_selections", return_value=(None, None, None)): response = view.post(request, device_id=1) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device not found" in response.content def test_device_role_update_renders_row(self): """DeviceRoleUpdateView renders row when device found.""" @@ -1589,8 +1624,8 @@ def _make_view(self): view.request = MagicMock() return view - def test_existing_device_not_found_returns_404(self): - """Line 1008-1009: Device.objects.get raises DoesNotExist β†’ 404.""" + def test_existing_device_not_found_renders_htmx_error_toast(self): + """Line 1008-1009: Device.objects.get raises DoesNotExist β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1605,7 +1640,9 @@ def test_existing_device_not_found_returns_404(self): MockDevice.objects.get.side_effect = ValueError("Not found") response = view.post(request, device_id=1) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Existing device not found" in response.content class TestDeviceConflictActionMorePaths: @@ -1625,8 +1662,8 @@ def _base_patches(self, view, mock_existing, libre_device, validation): return ExitStack() - def test_unknown_action_returns_400(self): - """Line 1338: unknown action returns 400.""" + def test_unknown_action_renders_htmx_error_toast(self): + """Line 1338: unknown action renders htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1653,9 +1690,11 @@ def test_unknown_action_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Unknown action: unknown_action_xyz" in response.content - def test_force_required_without_force_returns_400(self): + def test_force_required_without_force_renders_htmx_error_toast(self): """Lines 1044/1047-1048: device_type_mismatch + force required but not provided.""" view = self._make_view() request = _make_request( @@ -1683,10 +1722,12 @@ def test_force_required_without_force_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device type mismatch detected" in response.content - def test_validated_existing_pk_mismatch_returns_400(self): - """Line 1027: validated_existing.pk != existing_device.pk β†’ 400.""" + def test_validated_existing_pk_mismatch_renders_htmx_error_toast(self): + """Line 1027: validated_existing.pk != existing_device.pk β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1717,10 +1758,12 @@ def test_validated_existing_pk_mismatch_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device ID mismatch" in response.content - def test_validated_existing_none_returns_400(self): - """Line 1025: validated_existing is None β†’ 400.""" + def test_validated_existing_none_renders_htmx_error_toast(self): + """Line 1025: validated_existing is None β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1748,7 +1791,9 @@ def test_validated_existing_none_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Missing validated conflict target" in response.content def test_require_object_permissions_fails(self): """Line 1014: require_object_permissions returns error.""" @@ -1774,8 +1819,8 @@ def test_require_object_permissions_fails(self): assert response.status_code == 403 - def test_migrate_not_flagged_returns_400(self): - """Line 1252-1255: migrate_librenms_id with unflagged device β†’ 400.""" + def test_migrate_not_flagged_renders_htmx_error_toast(self): + """Line 1252-1255: migrate_librenms_id with unflagged device β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1804,10 +1849,12 @@ def test_migrate_not_flagged_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"already in JSON format" in response.content - def test_migrate_already_json_format_returns_400(self): - """Lines 1260-1265: cf_value already dict β†’ 400.""" + def test_migrate_already_json_format_renders_htmx_error_toast(self): + """Lines 1260-1265: cf_value already dict β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1837,10 +1884,12 @@ def test_migrate_already_json_format_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"already in JSON format" in response.content - def test_migrate_id_mismatch_returns_400(self): - """Line 1272-1275: cf_int != librenms_id β†’ 400.""" + def test_migrate_id_mismatch_renders_htmx_error_toast(self): + """Line 1272-1275: cf_int != librenms_id β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1870,10 +1919,12 @@ def test_migrate_id_mismatch_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"does not match the active device ID" in response.content - def test_sync_device_type_no_match_returns_400(self): - """Line 1241: sync_device_type with no HW match β†’ 400.""" + def test_sync_device_type_no_match_renders_htmx_error_toast(self): + """Line 1241: sync_device_type with no HW match β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1905,10 +1956,12 @@ def test_sync_device_type_no_match_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"No matching device type for" in response.content - def test_sync_platform_no_os_returns_400(self): - """Line 1227: sync_platform with empty OS β†’ 400.""" + def test_sync_platform_no_os_renders_htmx_error_toast(self): + """Line 1227: sync_platform with empty OS β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1936,10 +1989,12 @@ def test_sync_platform_no_os_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"No OS info from LibreNMS" in response.content def test_sync_platform_not_found_in_netbox(self): - """Line 1225: sync_platform platform not in NetBox β†’ 400.""" + """Line 1225: sync_platform platform not in NetBox β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -1970,7 +2025,9 @@ def test_sync_platform_not_found_in_netbox(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"not found in NetBox" in response.content class TestDeviceConflictUpdateAction: @@ -2098,8 +2155,8 @@ def _make_view(self): view.request = MagicMock() return view - def test_bool_librenms_id_returns_400(self): - """Line 1044: librenms_id is a boolean β†’ 400.""" + def test_bool_librenms_id_renders_htmx_error_toast(self): + """Line 1044: librenms_id is a boolean β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -2126,10 +2183,12 @@ def test_bool_librenms_id_returns_400(self): ): response = view.post(request, device_id=1) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Invalid or missing LibreNMS device_id in payload" in response.content - def test_non_int_librenms_id_returns_400(self): - """Lines 1047-1048: librenms_id is non-int string β†’ 400.""" + def test_non_int_librenms_id_renders_htmx_error_toast(self): + """Lines 1047-1048: librenms_id is non-int string β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -2156,7 +2215,9 @@ def test_non_int_librenms_id_returns_400(self): ): response = view.post(request, device_id=1) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Invalid or missing LibreNMS device_id in payload" in response.content class TestDeviceConflictLinkIdConflict: @@ -2170,8 +2231,8 @@ def _make_view(self): view.request = MagicMock() return view - def test_id_conflict_returns_409(self): - """Lines 1075-1079: LibreNMS ID conflict β†’ 409.""" + def test_id_conflict_renders_htmx_error_toast(self): + """Lines 1075-1079: LibreNMS ID conflict β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -2210,7 +2271,9 @@ def test_id_conflict_returns_409(self): mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"LibreNMS ID conflict" in response.content class TestBulkImportConfirmViewVMRole: @@ -2338,7 +2401,8 @@ def test_save_device_validation_error(self): result = _save_device(mock_device) assert result is not None - assert result.status_code == 400 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" def test_save_device_integrity_error(self): """Lines 54-56: IntegrityError during save.""" @@ -2351,7 +2415,8 @@ def test_save_device_integrity_error(self): result = _save_device(mock_device) assert result is not None - assert result.status_code == 409 # IntegrityError returns 409 + assert result.status_code == 200 + assert result.headers.get("HX-Reswap") == "none" def test_should_enable_vc_detection_when_cached(self): """Line 168: VC data already cached β†’ returns True.""" @@ -2390,8 +2455,8 @@ def _make_view(self): view.request = MagicMock() return view - def test_device_deleted_during_lock_returns_409(self): - """Lines 1069-1073: Device.DoesNotExist during select_for_update β†’ 409.""" + def test_device_deleted_during_lock_renders_htmx_error_toast(self): + """Lines 1069-1073: Device.DoesNotExist during select_for_update β†’ htmx error toast (200).""" view = self._make_view() request = _make_request( post={ @@ -2427,7 +2492,9 @@ def test_device_deleted_during_lock_returns_409(self): mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device no longer exists" in response.content class TestMigrateLibreNMSIdMorePaths: @@ -2461,8 +2528,8 @@ def _make_base_context(self, mock_existing): {}, ) - def test_serial_not_confirmed_no_force_returns_400(self): - """Line 1277-1280: serial not confirmed, no force β†’ 400.""" + def test_serial_not_confirmed_no_force_renders_htmx_error_toast(self): + """Line 1277-1280: serial not confirmed, no force β†’ htmx error toast (200).""" view = self._make_view() request = self._make_base_request() @@ -2486,7 +2553,9 @@ def test_serial_not_confirmed_no_force_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Serial number not confirmed" in response.content def test_migration_succeeds_and_renders_row(self): """Lines 1282-1323: successful migration renders row.""" @@ -2637,8 +2706,8 @@ def test_link_save_error_returns_error(self): assert response.status_code == 400 - def test_update_serial_conflict_returns_409(self): - """Line 1139: update_serial with serial conflict β†’ 409.""" + def test_update_serial_conflict_renders_htmx_error_toast(self): + """Line 1139: update_serial with serial conflict β†’ htmx error toast (200).""" view, request, mock_existing, libre_device, validation = self._base_setup("update_serial") conflict_device = MagicMock() conflict_device.name = "router99" @@ -2650,7 +2719,9 @@ def test_update_serial_conflict_returns_409(self): with patch("netbox_librenms_plugin.views.imports.actions._save_device", return_value=None): response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Serial conflict" in response.content def test_update_serial_save_success_renders_row(self): """Lines 1146-1149: update_serial with no conflict β†’ save + render.""" @@ -2687,8 +2758,8 @@ def test_sync_name_save_error(self): assert response.status_code == 400 - def test_update_type_no_device_type_returns_400(self): - """Line 1171: update_type with no librenms_device_type β†’ 400.""" + def test_update_type_no_device_type_renders_htmx_error_toast(self): + """Line 1171: update_type with no librenms_device_type β†’ htmx error toast (200).""" view, request, mock_existing, libre_device, validation = self._base_setup("update_type") # No device_type_mismatch + no force β†’ librenms_device_type = None @@ -2696,7 +2767,9 @@ def test_update_type_no_device_type_returns_400(self): with patch("netbox_librenms_plugin.views.imports.actions._save_device", return_value=None): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"No LibreNMS device type available to update" in response.content def test_sync_platform_success_renders_row(self): """Line 1222: sync_platform with found platform β†’ save + render.""" @@ -2732,7 +2805,7 @@ def test_sync_device_type_success_renders_row(self): mock_render.assert_called_once() - def test_device_not_found_after_action_returns_404(self): + def test_device_not_found_after_action_renders_htmx_error_toast(self): """Line 1338: get_validated_device_with_selections returns None after action.""" view, request, mock_existing, libre_device, validation = self._base_setup("sync_name") @@ -2751,7 +2824,9 @@ def side_effect(*args, **kwargs): with patch("netbox_librenms_plugin.views.imports.actions._save_device", return_value=None): response = view.post(request, device_id=42) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device not found after action" in response.content class TestMoreSaveErrorPaths: @@ -2824,7 +2899,7 @@ def _setup_common(self, view, mock_existing, libre_device, validation, save_retu return stack, MockDevice def test_update_serial_conflict_in_update(self): - """Line 1108: update action with serial conflict β†’ 409.""" + """Line 1108: update action with serial conflict β†’ htmx error toast (200).""" view, request, mock_existing, libre_device, validation = self._base_setup("update") conflict = MagicMock() conflict.name = "other" @@ -2835,7 +2910,9 @@ def test_update_serial_conflict_in_update(self): MockDevice.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = conflict response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Serial conflict" in response.content def test_update_with_device_type_mismatch_forced(self): """Lines 1116, 1119: update with force + device_type_mismatch β†’ device_type applied.""" @@ -2926,8 +3003,8 @@ def _make_view(self): view.request = MagicMock() return view - def test_sync_serial_no_serial_returns_400(self): - """Line 1210: sync_serial with empty serial β†’ 400.""" + def test_sync_serial_no_serial_renders_htmx_error_toast(self): + """Line 1210: sync_serial with empty serial β†’ htmx error toast (200).""" view = self._make_view() request = _make_request(post={"action": "sync_serial", "existing_device_id": "1"}) mock_existing = MagicMock() @@ -2950,7 +3027,9 @@ def test_sync_serial_no_serial_returns_400(self): ): response = view.post(request, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"No valid serial from LibreNMS" in response.content class TestUpdateAndSerialSaveErrors: @@ -3071,7 +3150,7 @@ def _common_patches_for_serial(self, view, mock_existing, libre_device, validati return stack, MockDevice, DoesNotExistExc def test_sync_serial_device_deleted_under_lock(self): - """Lines 1182-1183: Device.DoesNotExist during select_for_update β†’ 409.""" + """Lines 1182-1183: Device.DoesNotExist during select_for_update β†’ htmx error toast (200).""" view = self._make_view() request = _make_request(post={"action": "sync_serial", "existing_device_id": "1"}) mock_existing = MagicMock() @@ -3086,10 +3165,12 @@ def test_sync_serial_device_deleted_under_lock(self): MockDevice.objects.select_for_update.return_value.get.side_effect = DoesNotExistExc("gone") response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Device no longer exists" in response.content def test_sync_serial_conflict_under_lock(self): - """Lines 1196-1200: sync_serial serial conflict β†’ 409.""" + """Lines 1196-1200: sync_serial serial conflict β†’ htmx error toast (200).""" view = self._make_view() request = _make_request(post={"action": "sync_serial", "existing_device_id": "1"}) mock_existing = MagicMock() @@ -3111,7 +3192,9 @@ def test_sync_serial_conflict_under_lock(self): MockDevice.objects.filter.return_value.exclude.return_value.first.return_value = conflict_device response = view.post(request, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Serial conflict" in response.content def test_sync_serial_save_error(self): """Line 1207: sync_serial β†’ _save_device returns error.""" @@ -3186,7 +3269,7 @@ def _make_valid_migrate_context(self, view, extra_mock=None): return request, mock_existing, libre_device, validation, locked_device, MockDevice, DoesNotExistExc, mock_tx def test_migrate_device_deleted_under_lock(self): - """Lines 1285-1289: DoesNotExist during select_for_update β†’ 409.""" + """Lines 1285-1289: DoesNotExist during select_for_update β†’ htmx error toast (200).""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) MockDevice.objects.select_for_update.return_value.get.side_effect = DNE("gone") @@ -3198,10 +3281,12 @@ def test_migrate_device_deleted_under_lock(self): with patch("netbox_librenms_plugin.views.imports.actions.transaction", mock_tx): response = view.post(req, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Object no longer exists" in response.content def test_migrate_already_migrated_under_lock(self): - """Lines 1292-1298: cf_locked already dict under lock β†’ 400.""" + """Lines 1292-1298: cf_locked already dict under lock β†’ htmx error toast (200).""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) locked.custom_field_data = {"librenms_id": {"default": 42}} # Already migrated under lock @@ -3213,10 +3298,12 @@ def test_migrate_already_migrated_under_lock(self): with patch("netbox_librenms_plugin.views.imports.actions.transaction", mock_tx): response = view.post(req, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"already in JSON format" in response.content def test_migrate_id_changed_under_lock(self): - """Lines 1300-1303: cf_locked_int != librenms_id under lock β†’ 400.""" + """Lines 1300-1303: cf_locked_int != librenms_id under lock β†’ htmx error toast (200).""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) locked.custom_field_data = {"librenms_id": 99} # Different ID under lock @@ -3228,10 +3315,12 @@ def test_migrate_id_changed_under_lock(self): with patch("netbox_librenms_plugin.views.imports.actions.transaction", mock_tx): response = view.post(req, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"changed under lock" in response.content def test_migrate_id_conflict_with_other_device(self): - """Lines 1309-1315: another device already has this ID β†’ 409.""" + """Lines 1309-1315: another device already has this ID β†’ htmx error toast (200).""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) conflict_dev = MagicMock() @@ -3245,10 +3334,12 @@ def test_migrate_id_conflict_with_other_device(self): with patch("netbox_librenms_plugin.utils.find_by_librenms_id", return_value=conflict_dev): response = view.post(req, device_id=42) - assert response.status_code == 409 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Another device already has librenms_id" in response.content def test_migrate_migration_fails(self): - """Lines 1316-1320: migrate_legacy_librenms_id returns False β†’ 400.""" + """Lines 1316-1320: migrate_legacy_librenms_id returns False β†’ htmx error toast (200).""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) @@ -3263,15 +3354,17 @@ def test_migrate_migration_fails(self): ): response = view.post(req, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Migration failed" in response.content def test_migrate_save_error(self): - """Line 1321-1322: _save_device returns error.""" + """Migrate path saves only librenms_id field; IntegrityError on save β†’ htmx toast.""" view = self._make_view() req, mock_ex, libre, val, locked, MockDevice, DNE, mock_tx = self._make_valid_migrate_context(view) - from django.http import HttpResponse + from django.db import IntegrityError - err = HttpResponse("save error", status=400) + locked.save.side_effect = IntegrityError("dup") with patch.object(view, "require_all_permissions", return_value=None): with patch("dcim.models.Device", MockDevice): @@ -3282,12 +3375,11 @@ def test_migrate_save_error(self): with patch( "netbox_librenms_plugin.utils.migrate_legacy_librenms_id", return_value=True ): - with patch( - "netbox_librenms_plugin.views.imports.actions._save_device", return_value=err - ): - response = view.post(req, device_id=42) + response = view.post(req, device_id=42) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" + assert b"Unable to migrate the LibreNMS mapping" in response.content def test_migrate_success_renders_row(self): """Lines 1323+: successful migration renders row.""" @@ -3604,6 +3696,37 @@ def get_side_effect(k, d=None): mock_redirect.assert_called() + def test_vc_detection_disabled_in_post_is_passed_to_device_import(self): + """vc_detection_enabled=off from POST must propagate to bulk import call.""" + view = self._make_view() + request = self._make_base_request(["1"]) + request.POST.get = MagicMock(side_effect=lambda k, d=None: "off" if k == "enable_vc_detection" else None) + + with patch.object(view, "require_write_permission", return_value=None): + with patch( + "netbox_librenms_plugin.views.imports.actions.resolve_naming_preferences", return_value=(True, False) + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.bulk_import_devices", + return_value={"success": [], "failed": [], "skipped": [], "virtual_chassis_created": 0}, + ) as mock_bulk_import: + with patch( + "netbox_librenms_plugin.views.imports.actions.bulk_import_vms", + return_value={"success": [], "failed": [], "skipped": []}, + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.fetch_device_with_cache", + return_value={"device_id": 1}, + ): + with patch("netbox_librenms_plugin.views.imports.actions.messages"): + with patch( + "netbox_librenms_plugin.views.imports.actions.redirect", return_value=MagicMock() + ): + view.post(request) + + call_kwargs = mock_bulk_import.call_args.kwargs + assert call_kwargs["sync_options"]["vc_detection_enabled"] is False + def test_invalid_role_and_rack_values_log_warning(self): """Lines 534-535, 544-546: invalid role_id/rack_id β†’ warning.""" view = self._make_view() @@ -3906,11 +4029,15 @@ def test_permission_denied_htmx_returns_htmx_redirect(self): "netbox_librenms_plugin.views.imports.actions.resolve_naming_preferences", return_value=(True, False) ): with patch( - "netbox_librenms_plugin.views.imports.actions.bulk_import_devices", - side_effect=DjPD("No permission"), + "netbox_librenms_plugin.views.imports.actions.fetch_device_with_cache", + return_value={"device_id": 1, "hostname": "test-device"}, ): - with patch("netbox_librenms_plugin.views.imports.actions.messages"): - response = view.post(request) + with patch( + "netbox_librenms_plugin.views.imports.actions.bulk_import_devices", + side_effect=DjPD("No permission"), + ): + with patch("netbox_librenms_plugin.views.imports.actions.messages"): + response = view.post(request) assert response.headers.get("HX-Redirect") is not None diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 9b8ff6829..3e00037fd 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api.py +++ b/netbox_librenms_plugin/tests/test_coverage_api.py @@ -204,6 +204,28 @@ def test_dict_cf_routes_to_get_librenms_device_id(self): assert result is None mock_get_id.assert_called_once_with(obj, "default", auto_save=False) + def test_get_librenms_id_zero_is_valid(self): + """Regression: librenms_id == 0 must be returned directly without falling through to API lookup. + The code uses 'is not None' guards, so 0 is a valid cached/stored ID.""" + api = _make_api() + + obj = MagicMock() + obj.cf = {"librenms_id": {"default": 0}} + obj.custom_field_data = {"librenms_id": {"default": 0}} + obj._meta.model_name = "device" + obj.pk = 1 + obj.primary_ip = None + obj.name = "zero-device" + + # get_librenms_device_id returns 0 (valid ID, must not be treated as falsy) + with patch("netbox_librenms_plugin.utils.get_librenms_device_id", return_value=0) as mock_get_id: + with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache: + mock_cache.get.return_value = None + result = api.get_librenms_id(obj) + # 0 is a valid ID β€” method should return 0, not fall through to hostname lookup + assert result == 0 + mock_get_id.assert_called_once_with(obj, "default", auto_save=False) + def test_store_librenms_id_via_hostname_lookup(self): """get_librenms_id reaches _store_librenms_id when CF/cache miss but hostname API hit.""" api = _make_api() @@ -877,6 +899,7 @@ def test_add_device_with_port(self): with patch("requests.post", return_value=mock_resp) as mock_post: ok, msg = api.add_device(data) assert ok is True + assert msg == "Device added successfully." assert "port" in mock_post.call_args[1]["json"] def test_add_device_with_transport(self): @@ -888,6 +911,7 @@ def test_add_device_with_transport(self): with patch("requests.post", return_value=mock_resp) as mock_post: ok, msg = api.add_device(data) assert ok is True + assert msg == "Device added successfully." assert "transport" in mock_post.call_args[1]["json"] def test_add_device_with_port_association_mode(self): @@ -899,6 +923,7 @@ def test_add_device_with_port_association_mode(self): with patch("requests.post", return_value=mock_resp) as mock_post: ok, msg = api.add_device(data) assert ok is True + assert msg == "Device added successfully." assert "port_association_mode" in mock_post.call_args[1]["json"] def test_add_device_with_poller_group(self): @@ -910,6 +935,7 @@ def test_add_device_with_poller_group(self): with patch("requests.post", return_value=mock_resp) as mock_post: ok, msg = api.add_device(data) assert ok is True + assert msg == "Device added successfully." assert "poller_group" in mock_post.call_args[1]["json"] @@ -1199,15 +1225,14 @@ def test_get_device_vlans_none_vlans(self): assert ok is False assert msg is not None - def test_get_device_vlans_skips_non_dict_items(self): - """get_device_vlans: non-dict items in vlans list are skipped safely.""" + def test_get_device_vlans_fails_closed_on_non_dict_items(self): + """get_device_vlans: non-dict items in vlans list cause fail-closed (False, error message).""" api = _make_api() vlans = [None, "bad", {"device_id": 1, "vlan_id": 10}] with patch("requests.get", return_value=self._ok_resp({"status": "ok", "vlans": vlans})): - ok, data = api.get_device_vlans(1) - assert ok is True - assert len(data) == 1 - assert data[0]["vlan_id"] == 10 + ok, msg = api.get_device_vlans(1) + assert ok is False + assert "invalid item shape" in msg def test_get_device_ips_none_addresses(self): """get_device_ips: addresses=None returns (False, ...).""" diff --git a/netbox_librenms_plugin/tests/test_coverage_api2.py b/netbox_librenms_plugin/tests/test_coverage_api2.py index 65260421e..68bd4ad72 100644 --- a/netbox_librenms_plugin/tests/test_coverage_api2.py +++ b/netbox_librenms_plugin/tests/test_coverage_api2.py @@ -280,6 +280,8 @@ class _DoesNotExist(Exception): assert data["status"] == "updated" assert data["rq_status"] == "not_found" mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"]) + assert mock_db_job.status == JobStatusChoices.STATUS_FAILED + assert mock_db_job.completed == "2024-01-03" def test_no_change_when_not_running_and_not_in_rq(self): from core.choices import JobStatusChoices @@ -348,6 +350,14 @@ class _DoesNotExist(Exception): mock_tz.now.assert_not_called() assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "updated" + assert data["rq_status"] == "not_found" + # Verify the DB job was marked failed and persisted + assert mock_db_job.status == JobStatusChoices.STATUS_FAILED + mock_db_job.save.assert_called_once_with(update_fields=["status", "completed"]) + # completed must NOT be overwritten β€” it was already set + assert mock_db_job.completed == "2024-01-01T08:00:00" # =========================================================================== diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views.py b/netbox_librenms_plugin/tests/test_coverage_base_views.py index 94b18d438..5a691570a 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views.py @@ -14,7 +14,6 @@ from unittest.mock import MagicMock, patch - # ============================================================================= # Helpers # ============================================================================= @@ -1081,6 +1080,72 @@ def test_cache_hit_with_vc_uses_vc_members(self): # get_virtual_chassis_member should have been called with obj and the port name mock_get_vc_member.assert_called_once_with(obj, "Gi0/0") + def test_cache_hit_non_vc_ignores_duplicate_librenms_ids(self): + """Conflicting interface librenms_id values must not create an arbitrary port-id match.""" + view = self._make_view() + obj = _mock_obj() + obj.virtual_chassis = None + obj.id = 1 + request = _mock_request() + + cached_data = { + "ports": [ + { + "port_id": 101, + "ifName": "Gi0/99", + "ifAdminStatus": "up", + "ifAlias": None, + "ifDescr": "Gi0/99", + } + ] + } + + interface_a = MagicMock() + interface_a.id = 10 + interface_a.name = "Gi0/0" + interface_b = MagicMock() + interface_b.id = 11 + interface_b.name = "Gi0/1" + + mock_ifaces_qs = MagicMock() + mock_ifaces_qs.select_related.return_value = [interface_a, interface_b] + + rows_store = {} + + def capture_table(rows, *_args, **_kwargs): + rows_store["rows"] = rows + table = MagicMock() + table.configure = MagicMock() + return table + + with ( + patch.object(view, "get_cache_key", return_value="key"), + patch.object(view, "get_last_fetched_key", return_value="last-key"), + patch.object(view, "get_vlan_overrides_key", return_value="overrides-key"), + patch.object(view, "get_vlan_groups_for_device", return_value=[]), + patch.object(view, "_build_vlan_lookup_maps", return_value={"vid_to_groups": {}, "vid_to_vlans": {}}), + patch.object(view, "get_interfaces", return_value=mock_ifaces_qs), + patch.object(view, "_add_vlan_group_selection"), + patch.object(view, "_add_missing_vlans_info"), + patch.object(view, "get_table", side_effect=capture_table), + patch("netbox_librenms_plugin.views.base.interfaces_view.get_interface_name_field", return_value="ifName"), + patch("netbox_librenms_plugin.views.base.interfaces_view.cache") as mock_cache, + patch("netbox_librenms_plugin.views.base.interfaces_view.timezone") as mock_tz, + ): + view._librenms_api.get_stored_librenms_id.side_effect = lambda interface: 101 + mock_cache.get.side_effect = lambda key: cached_data if key == "key" else None + mock_cache.ttl.return_value = 300 + mock_tz.now.return_value = MagicMock() + mock_tz.timedelta.return_value = MagicMock() + view.get_context_data(request, obj, "ifName") + + assert "rows" in rows_store + assert len(rows_store["rows"]) == 1 + assert rows_store["rows"][0]["exists_in_netbox"] is False + assert rows_store["rows"][0]["netbox_interface"] is None + assert view._librenms_api.get_stored_librenms_id.call_count == 2 + view._librenms_api.get_librenms_id.assert_not_called() + class TestBaseInterfaceTableViewAddVlanGroupSelection: """Tests for BaseInterfaceTableView._add_vlan_group_selection.""" @@ -2194,3 +2259,57 @@ def test_netbox_only_interfaces_non_vc_device_name_from_obj(self): gi01 = next((i for i in netbox_only if i["name"] == "Gi0/1"), None) assert gi01 is not None assert gi01["device_name"] == "router-1" + + +# ============================================================================= +# BaseIPAddressTableView._flag_management_ip +# ============================================================================= + + +class TestBaseIPAddressTableViewFlagManagementIp: + """Tests for marking the LibreNMS management-IP row (Set Primary IP support).""" + + def _make_view(self, librenms_id=42): + from netbox_librenms_plugin.views.base.ip_addresses_view import BaseIPAddressTableView + + view = object.__new__(BaseIPAddressTableView) + view.librenms_id = librenms_id + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_flags_matching_entry(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": "10.0.0.1"}) + data = [{"ip_address": "10.0.0.5"}, {"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + assert data[1].get("is_mgmt_ip") is True + + def test_no_flag_when_no_match(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": "192.0.2.9"}) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_no_librenms_id(self): + view = self._make_view(librenms_id=None) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + view._librenms_api.get_device_info.assert_not_called() + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_device_info_fails(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (False, None) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None + + def test_no_flag_when_mgmt_ip_blank(self): + view = self._make_view() + view._librenms_api.get_device_info.return_value = (True, {"ip": ""}) + data = [{"ip_address": "10.0.0.1"}] + view._flag_management_ip(data) + assert data[0].get("is_mgmt_ip") is None diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index e75abcc1b..5e039a61d 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views2.py @@ -1581,8 +1581,8 @@ def test_invalid_json_returns_400(self): data = json_mod.loads(response.content) assert data["status"] == "error" - def test_interface_from_device_used_when_no_cache(self): - """When no cache entry (port_id=None), first device interface is used.""" + def test_interface_from_device_used_when_cache_has_no_port_id(self): + """When cache contains an IP entry with no port_id, first device interface is used.""" import json as json_mod view = self._make_view() @@ -1610,18 +1610,12 @@ def test_interface_from_device_used_when_no_cache(self): patch.object(view, "_parse_ip_address", return_value=("10.0.0.1", 24)), patch.object(view, "get_cache_key", return_value="cache-key"), patch("netbox_librenms_plugin.views.base.ip_addresses_view.cache") as mock_cache, - patch.object( - view, - "_find_in_cache", - # Truthy cache_entry whose port_id (third element) is None - # β†’ both cache enrichment and interfaces.first() fallback run - return_value=({"ip_address": "10.0.0.1", "prefix_length": 24}, None, None), - ), patch.object(view, "_find_existing_ip", return_value=(False, False, None)), patch.object(view, "_determine_status", return_value="sync"), patch("netbox_librenms_plugin.views.base.ip_addresses_view.IPAddressTable") as MockTable, ): - mock_cache.get.return_value = None + # Cache has an entry for this IP but no port_id β†’ interfaces.first() fallback runs + mock_cache.get.return_value = {"ip_addresses": [{"ip_address": "10.0.0.1", "prefix_length": 24}]} mock_table_instance = MagicMock() mock_table_instance.render_status.return_value = "sync" MockTable.return_value = mock_table_instance @@ -1629,8 +1623,13 @@ def test_interface_from_device_used_when_no_cache(self): response = view.post(req) assert response.status_code == 200 - # Cache entry was truthy but had no port_id β†’ first device interface used + # Cache entry found but has no port_id β†’ first device interface used mock_obj.interfaces.first.assert_called_once() + # And that interface's name/url must actually flow into the rendered record, + # not just be queried. + rendered_record = mock_table_instance.render_status.call_args[0][1] + assert rendered_record["interface_name"] == "eth0" + assert rendered_record["interface_url"] == "/interface/1/" def test_verify_with_non_default_server_key(self): """server_key='secondary' propagates to get_cache_key call.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_bulk_import.py b/netbox_librenms_plugin/tests/test_coverage_bulk_import.py new file mode 100644 index 000000000..2c92406b7 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_coverage_bulk_import.py @@ -0,0 +1,2337 @@ +""" +Coverage tests for import_utils/bulk_import.py. + +Exercises error paths, cancellation flows, cache behaviour, +and edge cases in bulk_import_devices_shared and process_device_filters. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_job(logger=True): + """ + Return a minimal JobRunner-like mock. + + Args: + logger: If True (default) attach a MagicMock logger; if False set it + to None so the ``else: logger.warning(...)`` branches fire. + """ + job = MagicMock() + job.job = MagicMock() + job.job.job_id = "test-uuid-1234" + job.job.pk = 1 + job.logger = MagicMock() if logger else None + return job + + +def _make_rq_running(): + """RQ job that is actively running (not stopped/failed).""" + rq_job = MagicMock() + rq_job.is_stopped = False + rq_job.is_failed = False + rq_job.get_status.return_value = "started" + return rq_job + + +def _make_rq_stopped(): + """RQ job that has been stopped.""" + rq_job = MagicMock() + rq_job.is_stopped = True + rq_job.is_failed = False + rq_job.get_status.return_value = "stopped" + return rq_job + + +def _make_validation(existing_device=None, import_as_vm=False, issues=None): + """Minimal valid validation dict used throughout the tests.""" + return { + "resolved_name": "test-device", + "is_ready": True, + "can_import": True, + "status": "active", + "existing_device": existing_device, + "import_as_vm": import_as_vm, + "existing_match_type": None, + "virtual_chassis": {"is_stack": False}, + "site": {"found": True, "site": MagicMock()}, + "device_type": {"found": True, "device_type": MagicMock()}, + "device_role": {"found": True, "role": MagicMock()}, + "platform": {"found": True, "platform": MagicMock()}, + "cluster": {"found": True}, + "issues": issues or [], + } + + +def _make_import_result(success=True, device=None, message="Imported", error=None): + """Return value for mocked ``import_single_device``.""" + return { + "success": success, + "device": device or (MagicMock() if success else None), + "message": message, + "error": error or ("" if success else "Import failed"), + } + + +# =========================================================================== +# 1. TestBulkImportDevices# =========================================================================== + + +class TestBulkImportDevices: + """Tests for the thin ``bulk_import_devices`` wrapper.""" + + def test_delegates_to_shared_with_job_none(self): + """bulk_import_devices must call bulk_import_devices_shared with job=None.""" + with patch("netbox_librenms_plugin.import_utils.bulk_import.bulk_import_devices_shared") as mock_shared: + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices + + expected = { + "total": 2, + "success": [], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + mock_shared.return_value = expected + user = MagicMock() + + result = bulk_import_devices( + device_ids=[1, 2], + server_key="default", + sync_options={"use_sysname": True}, + manual_mappings_per_device={1: {"device_role_id": 5}}, + libre_devices_cache={1: {"hostname": "test"}}, + user=user, + ) + + mock_shared.assert_called_once_with( + device_ids=[1, 2], + server_key="default", + sync_options={"use_sysname": True}, + manual_mappings_per_device={1: {"device_role_id": 5}}, + libre_devices_cache={1: {"hostname": "test"}}, + job=None, + user=user, + ) + assert result == expected + + +# =========================================================================== +# 2. TestBulkImportDevicesShared# 183, 203-254 +# =========================================================================== + + +class TestBulkImportDevicesShared: + """Tests for ``bulk_import_devices_shared``.""" + + # ------------------------------------------------------------------ + # Lines 129 & 140 – "else: logger.warning(...)" when job.logger=None + # ------------------------------------------------------------------ + + def test_rq_stopped_logs_via_module_logger_when_job_logger_none(self): + """job.logger=None: module logger.warning fires on RQ stop.""" + job = _make_job(logger=False) # job.logger is None β†’ else branch + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch("netbox_librenms_plugin.import_utils.bulk_import.import_single_device") as mock_import, + patch("netbox_librenms_plugin.import_utils.bulk_import.logger") as mock_logger, + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_queue = MagicMock() + mock_get_queue.return_value = mock_queue + mock_rq_cls.fetch.return_value = _make_rq_stopped() + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + mock_import.assert_not_called() + mock_logger.warning.assert_called() + assert result["success"] == [] + + def test_rq_unavailable_does_not_cancel_import(self): + """When RQ/Redis is unavailable, _is_job_cancelled returns False β†’ import continues.""" + job = _make_job(logger=False) # job.logger is None + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch("netbox_librenms_plugin.import_utils.bulk_import.import_single_device") as mock_import, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("django_rq.get_queue", side_effect=Exception("RQ unavailable")), + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + mock_import.assert_called() + + # ------------------------------------------------------------------ + # Lines 146-147 – libre_devices_cache hit path + # ------------------------------------------------------------------ + + def test_libre_devices_cache_hit_skips_api_call(self): + """Devices in libre_devices_cache skip the API call.""" + libre_cache = { + 1: {"device_id": 1, "hostname": "cached-host"}, + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") as mock_api_cls, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + ): + mock_api = MagicMock() + mock_api.server_key = "default" + mock_api_cls.return_value = mock_api + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + # API.get_device_info should NOT have been called for this device + mock_api.get_device_info.assert_not_called() + assert len(result["success"]) == 1 + + # ------------------------------------------------------------------ + # Line 183 – job.logger.info("Imported device X of Y") + # ------------------------------------------------------------------ + + def test_successful_import_emits_job_progress_log(self): + """job.logger.info('Imported device X of Y') on success.""" + job = _make_job() + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_queue = MagicMock() + mock_get_queue.return_value = mock_queue + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + assert len(result["success"]) == 1 + job.logger.info.assert_any_call("Imported device 1 of 1") + + # ------------------------------------------------------------------ + # Lines 203-254 – virtual chassis creation (is_stack=True) + # ------------------------------------------------------------------ + + def test_vc_creation_triggered_for_stack(self): + """is_stack=True β†’ create_virtual_chassis_with_members called.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + mock_device = MagicMock() + mock_vc = MagicMock() + mock_vc.name = "VC-Stack" + + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [ + {"serial": "SN001", "position": 1}, + {"serial": "SN002", "position": 2}, + ], + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(device=mock_device), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=mock_vc, + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_called_once() + assert result["virtual_chassis_created"] == 1 + assert len(result["success"]) == 1 + + def test_vc_creation_with_job_logger(self): + """VC creation with job β†’ job.logger.info logged.""" + job = _make_job() + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + mock_vc = MagicMock() + mock_vc.name = "VC-Stack" + + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}], + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=mock_vc, + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_queue = MagicMock() + mock_get_queue.return_value = mock_queue + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + assert result["virtual_chassis_created"] == 1 + # Confirm job logger was called for the VC creation + assert any("VC" in c.args[0] for c in job.logger.info.call_args_list if c.args) + + def test_vc_creation_deduplicates_by_member_serials(self): + """Two devices with identical member serials β†’ VC created only once.""" + libre_cache = { + 1: {"device_id": 1, "hostname": "stack-1"}, + 2: {"device_id": 2, "hostname": "stack-2"}, + } + mock_vc = MagicMock() + mock_vc.name = "VC-Stack" + + # Both devices share the same physical stack (same member serials) + shared_vc_data = { + "is_stack": True, + "members": [ + {"serial": "SN001", "position": 1}, + {"serial": "SN002", "position": 2}, + ], + } + v1 = _make_validation() + v1["virtual_chassis"] = shared_vc_data + v2 = _make_validation() + v2["virtual_chassis"] = shared_vc_data + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=[v1, v2], + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=mock_vc, + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1, 2], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + assert mock_create_vc.call_count == 1 + assert result["virtual_chassis_created"] == 1 + + def test_vc_creation_failure_continues_import(self): + """VC creation exception β†’ import device still succeeds.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}], + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + side_effect=Exception("VC creation failed"), + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + # Import succeeded despite VC failure + assert len(result["success"]) == 1 + assert result["virtual_chassis_created"] == 0 + mock_create_vc.assert_called_once() + + def test_vc_creation_skipped_without_vc_permission(self): + """User lacks dcim.add_virtualchassis β†’ stack device import fails fast (PR #257).""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}, {"serial": "SN002", "position": 2}], + } + + user = MagicMock() + user.has_perm.side_effect = lambda p: p != "dcim.add_virtualchassis" + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ) as mock_import, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=user, + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_not_called() + mock_import.assert_not_called() + assert len(result["success"]) == 0 + assert len(result["failed"]) == 1 + assert "dcim.add_virtualchassis" in result["failed"][0]["error"] + assert result["virtual_chassis_created"] == 0 + + def test_vc_creation_skipped_without_permission_logs_job_warning(self): + """Missing VC permission with job context logs error via job.logger (PR #257).""" + job = _make_job() + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}], + } + + user = MagicMock() + user.has_perm.side_effect = lambda p: p != "dcim.add_virtualchassis" + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + ) as mock_create_vc, + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_queue = MagicMock() + mock_get_queue.return_value = mock_queue + mock_rq_cls.fetch.return_value = _make_rq_running() + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=user, + job=job, + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_not_called() + job.logger.error.assert_called() + assert len(result["success"]) == 0 + assert len(result["failed"]) == 1 + assert "dcim.add_virtualchassis" in result["failed"][0]["error"] + + def test_vc_with_no_members_falls_back_to_device_id_domain(self): + """No serials and no member fingerprint triggers device-id vc_domain fallback.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + validation = _make_validation() + validation["virtual_chassis"] = {"is_stack": True, "members": []} + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=MagicMock(name="vc"), + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_called_once() + assert result["virtual_chassis_created"] == 1 + + def test_vc_creation_proceeds_with_vc_permission(self): + """User has dcim.add_virtualchassis β†’ VC creation proceeds normally.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + mock_vc = MagicMock() + mock_vc.name = "VC-Stack" + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}, {"serial": "SN002", "position": 2}], + } + + user = MagicMock() + user.has_perm.return_value = True # all perms granted + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=mock_vc, + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=user, + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_called_once() + assert result["virtual_chassis_created"] == 1 + + def test_vc_no_member_serials_uses_device_id_domain(self): + """Members with no valid serials β†’ vc_domain falls back to device_id.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + mock_vc = MagicMock() + mock_vc.name = "VC-1" + + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [ + {"serial": None, "position": 1}, + {"serial": "-", "position": 2}, + ], + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + return_value=mock_vc, + ) as mock_create_vc, + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + mock_create_vc.assert_called_once() + assert result["virtual_chassis_created"] == 1 + + def test_failed_import_with_job_logs_error(self): + """result.success=False, device=None β†’ job.logger.error called.""" + job = _make_job() + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(success=False, device=None, error="Import failed"), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_queue = MagicMock() + mock_get_queue.return_value = mock_queue + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + assert len(result["failed"]) == 1 + job.logger.error.assert_called() + + def test_manual_mappings_applied_to_device(self): + """manual_mappings_per_device overrides are applied for the matching device.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + captured_mappings = {} + + def capture_import(device_id, server_key, validation, sync_options, manual_mappings, libre_device): + captured_mappings.update(manual_mappings or {}) + return _make_import_result() + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + side_effect=capture_import, + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + manual_mappings_per_device={1: {"device_role_id": 42}}, + ) + + assert result["success"] + assert captured_mappings.get("device_role_id") == 42 + + def test_device_skipped_when_already_exists(self): + """result.success=False, result.device is truthy β†’ device skipped.""" + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + existing_device = MagicMock() + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(success=False, device=existing_device, error="Device already exists"), + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + user=MagicMock(), + libre_devices_cache=libre_cache, + ) + + assert len(result["skipped"]) == 1 + assert result["skipped"][0]["reason"] == "Device already exists" + assert result["failed"] == [] + + def test_vc_creation_failure_with_job_logs_warning(self): + """VC failure with job.logger set β†’ job.logger.warning fired.""" + job = _make_job() + libre_cache = {1: {"device_id": 1, "hostname": "test"}} + validation = _make_validation() + validation["virtual_chassis"] = { + "is_stack": True, + "members": [{"serial": "SN001", "position": 1}], + } + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.import_single_device", + return_value=_make_import_result(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.create_virtual_chassis_with_members", + side_effect=Exception("VC error"), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + result = bulk_import_devices_shared( + device_ids=[1], + job=job, + libre_devices_cache=libre_cache, + ) + + assert len(result["success"]) == 1 + assert result["virtual_chassis_created"] == 0 + job.logger.warning.assert_called() + + +# =========================================================================== +# 3. TestRefreshExistingDevice# 400-402, 420-421 +# =========================================================================== + + +class TestRefreshExistingDevice: + """Tests for ``_refresh_existing_device``.""" + + # ------------------------------------------------------------------ + # Lines 336-341: Device path β†’ refreshed device with role + # ------------------------------------------------------------------ + + def test_device_path_refreshes_role(self): + """Non-VM existing device refreshed; role updated on result.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 1 + refreshed = MagicMock() + refreshed.role = MagicMock(name="switch") + + validation = { + "existing_device": existing, + "import_as_vm": False, + "device_role": {}, + } + + with patch("dcim.models.Device") as mock_Device: + mock_Device.objects.filter.return_value.first.return_value = refreshed + _refresh_existing_device(validation) + + assert validation["existing_device"] is refreshed + assert validation["device_role"]["found"] is True + assert validation["device_role"]["role"] is refreshed.role + + # ------------------------------------------------------------------ + # Lines 342-345: Device path β†’ refreshed device without role + # ------------------------------------------------------------------ + + def test_device_path_refreshes_no_role(self): + """Refreshed device has no role β†’ device_role = {'found': False}.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 2 + refreshed = MagicMock() + refreshed.role = None # role removed since caching + + validation = { + "existing_device": existing, + "import_as_vm": False, + "device_role": {"found": True, "role": MagicMock()}, + } + + with patch("dcim.models.Device") as mock_Device: + mock_Device.objects.filter.return_value.first.return_value = refreshed + _refresh_existing_device(validation) + + assert validation["existing_device"] is refreshed + assert validation["device_role"] == {"found": False, "role": None, "available_roles": []} + + # ------------------------------------------------------------------ + # Lines 346-365: Existing device was deleted (Device.objects returns None) + # ------------------------------------------------------------------ + + def test_deleted_device_clears_existing_and_recomputes_readiness(self): + """Device deleted β†’ existing_device=None, readiness recomputed.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 3 + validation = { + "existing_device": existing, + "import_as_vm": False, + "issues": [], + "site": {"found": True}, + "device_type": {"found": True}, + "device_role": {"found": True}, + } + + with patch("dcim.models.Device") as mock_Device: + mock_Device.objects.filter.return_value.first.return_value = None # deleted + _refresh_existing_device(validation) + + assert validation["existing_device"] is None + assert validation["existing_match_type"] is None + assert validation["device_role"] == {"found": False, "role": None, "available_roles": []} + assert validation["can_import"] is True # no issues + assert validation["is_ready"] is False # device_role.found is now missing + + def test_deleted_vm_recomputes_readiness_from_cluster(self): + """VM deleted β†’ is_ready reflects cluster.found.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 4 + validation = { + "existing_device": existing, + "import_as_vm": True, + "issues": [], + "cluster": {"found": True}, + } + + with patch("virtualization.models.VirtualMachine") as mock_VM: + mock_VM.objects.filter.return_value.first.return_value = None # deleted + _refresh_existing_device(validation) + + assert validation["existing_device"] is None + assert validation["can_import"] is True + assert validation["is_ready"] is True # cluster.found=True + + def test_deleted_vm_not_ready_when_no_cluster(self): + """Deleted VM with no cluster β†’ is_ready=False.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 5 + validation = { + "existing_device": existing, + "import_as_vm": True, + "issues": ["some issue"], + "cluster": {"found": False}, + } + + with patch("virtualization.models.VirtualMachine") as mock_VM: + mock_VM.objects.filter.return_value.first.return_value = None + _refresh_existing_device(validation) + + assert validation["can_import"] is False # has issues + assert validation["is_ready"] is False + + # ------------------------------------------------------------------ + # Lines 366-368: Exception caught β†’ logger.error called + # ------------------------------------------------------------------ + + def test_exception_during_refresh_logs_error(self): + """DB error during refresh is caught and logged.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + existing = MagicMock() + existing.pk = 99 + validation = {"existing_device": existing, "import_as_vm": False} + + with ( + patch("dcim.models.Device") as mock_Device, + patch("netbox_librenms_plugin.import_utils.bulk_import.logger") as mock_logger, + ): + mock_Device.objects.filter.side_effect = Exception("DB down") + _refresh_existing_device(validation) # must not raise + + mock_logger.error.assert_called_once() + assert "99" in mock_logger.error.call_args[0][0] + + # ------------------------------------------------------------------ + # Line 373: no existing_device, no libre_device β†’ early return + # ------------------------------------------------------------------ + + def test_no_existing_device_no_libre_device_returns_early(self): + """existing=None + libre_device=None β†’ immediate return.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + validation = {"existing_device": None} + # No exception and validation unchanged + _refresh_existing_device(validation, libre_device=None) + assert validation == {"existing_device": None} + + # ------------------------------------------------------------------ + # Lines 393-395: existing=None, found via librenms_id + # ------------------------------------------------------------------ + + def test_no_existing_device_found_by_librenms_id(self): + """existing=None: device found by librenms_id custom field.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + new_device = MagicMock() + new_device.role = MagicMock(name="switch") + libre_device = {"device_id": 42, "hostname": "sw01", "sysName": "sw01"} + + validation = { + "existing_device": None, + "import_as_vm": False, + "resolved_name": "sw01", + "device_role": {"found": False, "role": None}, + "site": {"found": True, "site": MagicMock()}, + "device_type": {"found": True, "device_type": MagicMock()}, + "issues": [], + } + + with ( + patch("dcim.models.Device") as mock_Device, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", + return_value=new_device, + ), + ): + mock_Device.objects.filter.return_value.first.return_value = None + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + assert validation["existing_device"] is new_device + assert validation["existing_match_type"] == "librenms_id" + # Late-found existing match must never be import-ready, even if recalculate + # would otherwise set can_import=True (no issues + all fields found). + assert validation["can_import"] is False + assert validation["is_ready"] is False + # Device has a role β†’ device_role should be set + assert validation["device_role"]["found"] is True + + # ------------------------------------------------------------------ + # Lines 400-402: existing=None, found by resolved_name + # ------------------------------------------------------------------ + + def test_no_existing_device_found_by_resolved_name(self): + """existing=None: not found by librenms_id, but found by resolved_name.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + new_device = MagicMock() + new_device.role = None # no role + libre_device = {"device_id": 43, "hostname": "sw02", "sysName": "sw02"} + + validation = { + "existing_device": None, + "import_as_vm": False, + "resolved_name": "sw02-resolved", + } + + with ( + patch("dcim.models.Device") as mock_Device, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", + return_value=None, + ), + ): + # resolved_name match + mock_Device.objects.filter.return_value.first.return_value = new_device + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + assert validation["existing_device"] is new_device + assert validation["existing_match_type"] == "resolved_name" + assert validation["can_import"] is False + + # ------------------------------------------------------------------ + # Lines 420-421: exception in the "no existing device" lookup path + # ------------------------------------------------------------------ + + def test_exception_during_new_device_lookup_logs_error(self): + """Exception in the newly-imported-device check is caught and logged.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + libre_device = {"device_id": 44, "hostname": "sw03", "sysName": "sw03"} + validation = {"existing_device": None, "import_as_vm": False, "resolved_name": None} + + with ( + patch("dcim.models.Device"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", + side_effect=Exception("lookup failed"), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.logger") as mock_logger, + ): + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + mock_logger.error.assert_called() + + def test_no_existing_device_non_numeric_librenms_id_skips_id_lookup(self): + """Non-numeric device_id raises ValueError β†’ except pass, falls back to name.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + new_device = MagicMock() + new_device.role = None + # Non-numeric device_id triggers ValueError inside int() β†’ except (ValueError, TypeError): pass + libre_device = {"device_id": "not-an-int", "hostname": "sw05", "sysName": "sw05"} + + validation = { + "existing_device": None, + "import_as_vm": False, + "resolved_name": "sw05", + } + + with ( + patch("dcim.models.Device") as mock_Device, + patch("netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id") as mock_find, + ): + mock_Device.objects.filter.return_value.first.return_value = new_device + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + # Should find via resolved_name (librenms_id lookup was skipped due to ValueError) + assert validation["existing_device"] is new_device + assert validation["existing_match_type"] == "resolved_name" + # Crucially: find_by_librenms_id must never have been called β€” int("not-an-int") + # raises ValueError before the call site is reached. + mock_find.assert_not_called() + + def test_no_existing_device_hostname_fallback(self): + """existing=None, not found by id or resolved_name β†’ hostname fallback.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + new_device = MagicMock() + new_device.role = None + libre_device = {"device_id": 45, "hostname": "sw04", "sysName": "sw04-sysname"} + + validation = { + "existing_device": None, + "import_as_vm": False, + "resolved_name": None, # no resolved_name β†’ fall through to hostname + } + + # filter returns new_device only for the hostname kwargs; any other filter + # call (e.g. resolved_name or sysname) returns None β€” so the test fails if + # the wrong lookup path is exercised. + def filter_first(*args, **kwargs): + m = MagicMock() + m.first.return_value = new_device if kwargs.get("name__iexact") == "sw04" else None + return m + + with ( + patch("dcim.models.Device") as mock_Device, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", + return_value=None, + ), + ): + mock_Device.objects.filter.side_effect = filter_first + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + assert validation["existing_device"] is new_device + assert validation["existing_match_type"] == "hostname" + # Verify the hostname-keyed filter call was actually made + mock_Device.objects.filter.assert_any_call(name__iexact="sw04") + + +# =========================================================================== +# 4. TestProcessDeviceFilters# 548-566, 596-598, 604-641, 665, 687-694 +# =========================================================================== + + +class TestProcessDeviceFilters: + """Tests for ``process_device_filters``.""" + + # Minimal set of patches required for every call + _BASE_PATCHES = [ + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + "netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices", + "netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", + "netbox_librenms_plugin.import_utils.bulk_import.cache", + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + ] + + def _make_api(self, server_key="default", cache_timeout=300): + api = MagicMock() + api.server_key = server_key + api.cache_timeout = cache_timeout + return api + + def _make_device(self, device_id=1, hostname="sw01"): + return {"device_id": device_id, "hostname": hostname, "disabled": 0} + + # ------------------------------------------------------------------ + # Lines 469, 489: job logger on fetch and device-count messages + # ------------------------------------------------------------------ + + def test_job_logs_fetch_and_count_messages(self): + """With job set, info logs for 'Fetching' and 'Found N devices' fire.""" + job = _make_job() + device = self._make_device() + api = self._make_api() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_cache.get.side_effect = lambda key, default=None: default # no cache hit + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert len(result) == 1 + # Verify the two job-specific log calls were made + log_calls = [c.args[0] for c in job.logger.info.call_args_list] + assert any("Fetching" in s for s in log_calls) + assert any("Found" in s for s in log_calls) + + # ------------------------------------------------------------------ + # Lines 495-506: VC prefetch with job logging + # ------------------------------------------------------------------ + + def test_vc_prefetch_with_job_logs_prefetch_messages(self): + """With vc_detection_enabled+job, prefetch job-log messages fire.""" + job = _make_job() + device = self._make_device() + api = self._make_api() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices") as mock_prefetch, + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_cache.get.side_effect = lambda key, default=None: default + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + process_device_filters( + api, + filters={}, + vc_detection_enabled=True, + clear_cache=True, + show_disabled=True, + job=job, + ) + + mock_prefetch.assert_called_once() + log_calls = [c.args[0] for c in job.logger.info.call_args_list] + assert any("Pre-fetch" in s or "pre-fetch" in s or "virtual chassis" in s.lower() for s in log_calls) + + # ------------------------------------------------------------------ + # Lines 507-511: BrokenPipeError during VC prefetch + request set + # ------------------------------------------------------------------ + + def test_vc_prefetch_client_disconnect_with_request_returns_empty(self): + """BrokenPipeError during prefetch + request set β†’ _empty_return.""" + api = self._make_api() + request = MagicMock() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices", + side_effect=BrokenPipeError("client gone"), + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=True, + request=request, + ) + + assert result == [] + + def test_vc_prefetch_client_disconnect_with_return_cache_status(self): + """BrokenPipeError + request + return_cache_status=True β†’ ([], False).""" + api = self._make_api() + request = MagicMock() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices", + side_effect=BrokenPipeError("client gone"), + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=True, + request=request, + return_cache_status=True, + ) + + assert result == ([], False) + + def test_vc_prefetch_client_disconnect_no_request_reraises(self): + """BrokenPipeError during prefetch with request=None β†’ exception re-raised.""" + + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices", + side_effect=BrokenPipeError("client gone"), + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + with pytest.raises(BrokenPipeError): + process_device_filters( + api, + filters={}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=True, + request=None, + ) + + # ------------------------------------------------------------------ + # Lines 520-531: Job pre-loop RQ check β†’ job was already stopped + # ------------------------------------------------------------------ + + def test_job_rq_stopped_before_validation_loop_returns_empty(self): + """RQ job stopped before loop β†’ empty result returned.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_stopped() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert result == [] + job.logger.warning.assert_called() + + def test_job_rq_stopped_before_loop_with_cache_status(self): + """RQ job stopped + return_cache_status=True β†’ ([], False).""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_stopped() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + return_cache_status=True, + ) + + assert result == ([], False) + + # ------------------------------------------------------------------ + # Lines 532-537: Job pre-loop RQ raises β†’ DB fallback β†’ stopped + # ------------------------------------------------------------------ + + def test_job_cancelled_before_validation_loop_returns_empty(self): + """Job cancelled at pre-loop check β†’ returns empty list.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import._is_job_cancelled", return_value=True), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert result == [] + + def test_rq_unavailable_job_not_cancelled_in_preloop(self): + """RQ unavailable before loop β†’ _is_job_cancelled returns False β†’ processing continues.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch("django_rq.get_queue", side_effect=Exception("RQ down")), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.side_effect = lambda key, default=None: default + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert len(result) == 1 + + # ------------------------------------------------------------------ + # Lines 548-560: Per-device loop RQ stop detected + # ------------------------------------------------------------------ + + def test_job_validation_loop_rq_stop_returns_empty(self): + """RQ stop detected during loop at idx=1 β†’ return empty.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_get_queue.return_value = MagicMock() + # _is_job_cancelled is called 4 times before the in-loop check: + # pre-fetch, pre-VC, pre-validation-loop, then once per device. + mock_rq_cls.fetch.side_effect = [ + _make_rq_running(), + _make_rq_running(), + _make_rq_running(), + _make_rq_stopped(), + ] + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert result == [] + + # ------------------------------------------------------------------ + # Lines 561-566: Per-device loop DB fallback stop + # ------------------------------------------------------------------ + + def test_job_cancelled_in_validation_loop_returns_empty(self): + """Job cancellation detected during loop β†’ returns empty list.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import._is_job_cancelled", + side_effect=[False, False, False, True], # 3 pre-loop checks pass, in-loop check cancels + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert result == [] + + # ------------------------------------------------------------------ + # Lines 584-601: Cache-hit path (clear_cache=False + cache miss=None) + # NOTE: cache hit with exclude_existing=False is in existing tests. + # ------------------------------------------------------------------ + + def test_cache_hit_uses_cached_validation(self): + """Cache hit β†’ device validation taken from cache.""" + api = self._make_api() + device = self._make_device() + + existing = MagicMock() + cached_validation = _make_validation() + cached_validation["existing_device"] = existing # truthy β†’ refresh skips new-device lookup + cached_entry = dict(device) + cached_entry["_validation"] = cached_validation + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], True), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("netbox_librenms_plugin.import_utils.bulk_import._refresh_existing_device"), + patch("netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import") as mock_validate, + ): + # First get β†’ device cache hit; second get β†’ metadata (truthy) + mock_cache.get.side_effect = [cached_entry, MagicMock()] + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=True, + ) + + # validate_device_for_import must NOT be called (cache hit) + mock_validate.assert_not_called() + assert len(result) == 1 + + # ------------------------------------------------------------------ + # Lines 596-598: Cache-hit + exclude_existing β†’ device skipped + # ------------------------------------------------------------------ + + def test_cache_hit_with_exclude_existing_skips_device(self): + """Cache hit + exclude_existing + existing_device β†’ device skipped.""" + api = self._make_api() + device = self._make_device() + + existing = MagicMock() + existing.pk = 1 + cached_validation = _make_validation(existing_device=existing) + # Ensure _refresh_existing_device returns quickly without a real DB call + cached_validation["existing_device"] = existing + cached_entry = dict(device) + cached_entry["_validation"] = cached_validation + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], True), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + # Patch _refresh_existing_device to be a no-op so we can control existing_device + patch("netbox_librenms_plugin.import_utils.bulk_import._refresh_existing_device"), + ): + mock_cache.get.return_value = cached_entry + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=True, + exclude_existing=True, + ) + + # Device should be excluded because existing_device is set + assert result == [] + + # ------------------------------------------------------------------ + # Lines 604-641: Validate-and-cache path (clear_cache=True) + # ------------------------------------------------------------------ + + def test_validate_and_cache_path_no_vc_detection(self): + """clear_cache=True β†’ validate + set empty VC data + cache stored.""" + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", + return_value={"is_stack": False}, + ) as mock_empty_vc, + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.side_effect = lambda key, default=None: default # no cache hit + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + ) + + assert len(result) == 1 + # empty_virtual_chassis_data should have been called for vc=False + mock_empty_vc.assert_called() + # cache.set called for the validated device and simple key + assert mock_cache.set.call_count >= 2 + + def test_validate_path_exclude_existing_skips_device(self): + """validate path + exclude_existing + existing_device β†’ device skipped.""" + api = self._make_api() + device = self._make_device() + + validation_with_existing = _make_validation(existing_device=MagicMock()) + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation_with_existing, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.return_value = None + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + exclude_existing=True, + ) + + assert result == [] + + def test_validate_path_client_disconnect_with_request_returns_empty(self): + """validate raises BrokenPipeError + request set β†’ _empty_return.""" + api = self._make_api() + request = MagicMock() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=BrokenPipeError("client gone"), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + ): + mock_cache.get.return_value = None + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + request=request, + ) + + assert result == [] + + def test_validate_path_client_disconnect_no_request_reraises(self): + """validate raises BrokenPipeError, request=None β†’ re-raised.""" + + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=BrokenPipeError("client gone"), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + ): + mock_cache.get.return_value = None + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + with pytest.raises(BrokenPipeError): + process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + request=None, + ) + + # ------------------------------------------------------------------ + # Line 665: pass – metadata already exists and should_update=False + # ------------------------------------------------------------------ + + def test_cache_metadata_not_updated_when_from_cache_and_existing(self): + """from_cache=True + existing metadata β†’ metadata preserved (pass branch).""" + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], True), # from_cache=True + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + existing_metadata = {"cached_at": "2024-01-01T00:00:00+00:00", "cache_timeout": 300} + # First cache.get β†’ None (no device cache hit β†’ forces validation) + # Second cache.get β†’ existing metadata (truthy) + mock_cache.get.side_effect = [None, existing_metadata] + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=False, # not clearing cache + show_disabled=True, + ) + + assert len(result) == 1 + # cache.set for metadata should NOT have been called (existing preserved) + set_calls = [c for c in mock_cache.set.call_args_list if c.args[0] == "mkey"] + assert len(set_calls) == 0 + + # ------------------------------------------------------------------ + # Lines 604-641: Cache metadata stored when clear_cache=False + from_cache=False + # ------------------------------------------------------------------ + + def test_cache_metadata_stored_when_fresh_data(self): + """Fresh data (from_cache=False) β†’ metadata and index stored.""" + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), # from_cache=False + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.side_effect = lambda key, default=None: default # no cache hit anywhere + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + ) + + assert len(result) == 1 + # cache.set must have been called at least for metadata + assert mock_cache.set.call_count >= 1 + + # ------------------------------------------------------------------ + # Lines 687-694: Final job logging + # ------------------------------------------------------------------ + + def test_job_final_log_without_exclude_existing(self): + """With job + validated devices (no exclude_existing) β†’ final log.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_cache.get.side_effect = lambda key, default=None: default + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + exclude_existing=False, + job=job, + ) + + assert len(result) == 1 + final_calls = [str(c) for c in job.logger.info.call_args_list] + assert any("Validation complete" in s for s in final_calls) + + def test_job_final_log_with_exclude_existing(self): + """With job + exclude_existing + some devices filtered β†’ extended final log.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + # Device will be excluded because it has an existing_device + validation_with_existing = _make_validation(existing_device=MagicMock()) + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation_with_existing, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rq_cls, + ): + mock_cache.get.return_value = None + mock_get_queue.return_value = MagicMock() + mock_rq_cls.fetch.return_value = _make_rq_running() + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + exclude_existing=True, + job=job, + ) + + # Device excluded β†’ empty result; job should log the "filtered out" message + assert result == [] + final_calls = [str(c) for c in job.logger.info.call_args_list] + assert any("filtered out" in s for s in final_calls) + + # ------------------------------------------------------------------ + # Lines 698-699: return_cache_status=True β†’ returns tuple + # ------------------------------------------------------------------ + + def test_return_cache_status_true_returns_tuple(self): + """return_cache_status=True β†’ (devices, from_cache) tuple.""" + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], True), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.side_effect = lambda key, default=None: default + + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + return_cache_status=True, + ) + + assert isinstance(result, tuple) + devices, from_cache = result + assert len(devices) == 1 + assert from_cache is True + + def test_rq_fetch_exception_does_not_cancel_process_filters(self): + """RQ Job.fetch raises β†’ _is_job_cancelled returns False β†’ processing continues.""" + job = _make_job() + api = self._make_api() + device = self._make_device() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=_make_validation(), + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + patch("django_rq.get_queue") as mock_get_queue, + ): + mock_cache.get.side_effect = lambda key, default=None: default + mock_get_queue.return_value = MagicMock() + with patch("rq.job.Job") as mock_rq_cls: + mock_rq_cls.fetch.side_effect = Exception("RQ unavailable") + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + result = process_device_filters( + api, + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=True, + job=job, + ) + + assert len(result) == 1 + + +# =========================================================================== +# Issue #26 β€” device_role reset must NOT clear VMs +# =========================================================================== + + +class TestDeviceRoleResetGuard: + """#26: device_role should only be reset when the device was deleted and import_as_vm is False.""" + + def _call_refresh(self, validation, libre_device=None): + """Simulate a device that was found at cache time but has since been deleted.""" + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + # Set a mock existing_device so the function takes the "deleted" path + existing = MagicMock() + existing.pk = 42 + validation["existing_device"] = existing + + with patch("dcim.models.Device") as mock_dev_cls: + with patch("virtualization.models.VirtualMachine") as mock_vm_cls: + mock_dev_cls.objects.filter.return_value.first.return_value = None # deleted + mock_vm_cls.objects.filter.return_value.first.return_value = None # deleted + _refresh_existing_device(validation, server_key="default") + + def test_device_role_reset_for_plain_device(self): + """When import_as_vm=False and device deleted, device_role is reset to not-found but available_roles preserved.""" + mock_role = MagicMock() + validation = _make_validation(import_as_vm=False) + validation["device_role"] = {"found": True, "role": mock_role} + self._call_refresh(validation) + assert validation["device_role"] == {"found": False, "role": None, "available_roles": []} + + def test_device_role_preserved_for_vm(self): + """When import_as_vm=True and device deleted, device_role must NOT be cleared.""" + mock_role = MagicMock() + validation = _make_validation(import_as_vm=True) + validation["device_role"] = {"found": True, "role": mock_role} + self._call_refresh(validation) + # device_role should remain untouched + assert validation["device_role"]["found"] is True + assert validation["device_role"]["role"] is mock_role + + +# =========================================================================== +# Issue #28 β€” cache index TTL always refreshed +# =========================================================================== + + +class TestCacheIndexTTLRefresh: + """#28: cache index must be re-written even when the key is already present.""" + + def _make_api(self, server_key="default", cache_timeout=300): + api = MagicMock() + api.server_key = server_key + api.cache_timeout = cache_timeout + return api + + def _run_process_with_cache(self, cache_index_before, api=None): + """Run process_device_filters with a pre-seeded cache index and return the mock_cache.""" + if api is None: + api = self._make_api() + device = {"device_id": 1, "hostname": "sw01", "sysName": "sw01"} + validation = _make_validation() + + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=([device], False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + return_value=validation, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.empty_virtual_chassis_data", return_value={}), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", + return_value="vkey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_import_device_cache_key", + return_value="ikey", + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", + return_value="mkey", + ), + ): + mock_cache.get.side_effect = lambda key, default=None: ( + cache_index_before if "cache_index" in key else default + ) + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + process_device_filters(api, filters={}, vc_detection_enabled=False, clear_cache=False, show_disabled=False) + return mock_cache + + def test_cache_index_refreshed_when_key_already_present(self): + """cache.set is called for the index even when the metadata key is already present.""" + existing_key = "mkey" + mock_cache = self._run_process_with_cache(cache_index_before=[existing_key]) + # Find the cache.set call that updates the index + index_set_calls = [c for c in mock_cache.set.call_args_list if "cache_index" in (c.args[0] if c.args else "")] + assert len(index_set_calls) >= 1, "cache.set for cache_index was never called" + # The stored index must still contain the key (not duplicated) + stored_index = index_set_calls[0].args[1] + assert stored_index.count(existing_key) == 1 + + def test_cache_index_refreshed_for_new_key(self): + """cache.set is called when the key is new.""" + mock_cache = self._run_process_with_cache(cache_index_before=[]) + index_set_calls = [c for c in mock_cache.set.call_args_list if "cache_index" in (c.args[0] if c.args else "")] + assert len(index_set_calls) >= 1 + + +# =========================================================================== +# Issue #36 β€” cross-model conflict detection in stale cache refresh +# =========================================================================== + + +class TestCrossModelConflictDetection: + """#36: stale-cache refresh must detect device imported as VM (or vice versa).""" + + def test_vm_found_when_device_imported_as_vm(self): + """ + import_as_vm=False (cached as device) but the object was actually imported as VM. + The refresh should detect the VM and mark the import as conflicting. + """ + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + validation = _make_validation(import_as_vm=False) + # existing_device=None means "check if imported since caching" branch + validation["existing_device"] = None + libre_device = {"device_id": 99, "hostname": "sw-cross", "sysName": "sw-cross"} + + mock_vm = MagicMock() + mock_vm.role = None + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", return_value=None), + patch("dcim.models.Device") as mock_dev_cls, + patch("virtualization.models.VirtualMachine") as mock_vm_cls, + ): + # Primary model (Device) finds nothing; cross model (VirtualMachine) finds it + mock_dev_cls.objects.filter.return_value.first.return_value = None + mock_vm_cls.objects.filter.return_value.first.return_value = mock_vm + + _refresh_existing_device(validation, libre_device, server_key="default") + + assert validation["existing_device"] is mock_vm + # import_as_vm must be flipped to True so future refreshes query VirtualMachine + assert validation["import_as_vm"] is True + # A late-found cross-model match must never be import-ready: + # _refresh_existing_device re-asserts can_import=False/is_ready=False after + # recalculate_validation_status regardless of issues/fields state. + assert validation["can_import"] is False + assert validation["is_ready"] is False + + def test_device_found_when_vm_imported_as_device(self): + """ + import_as_vm=True but the object was imported as a Device. + The refresh should detect the Device through cross-model lookup. + """ + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + validation = _make_validation(import_as_vm=True) + validation["existing_device"] = None + libre_device = {"device_id": 77, "hostname": "vm-but-device", "sysName": "vm-but-device"} + + mock_device = MagicMock() + mock_device.role = MagicMock() + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", return_value=None), + patch("dcim.models.Device") as mock_dev_cls, + patch("virtualization.models.VirtualMachine") as mock_vm_cls, + ): + # Primary model (VirtualMachine) finds nothing; cross model (Device) finds it + mock_vm_cls.objects.filter.return_value.first.return_value = None + mock_dev_cls.objects.filter.return_value.first.return_value = mock_device + + _refresh_existing_device(validation, libre_device, server_key="default") + + assert validation["existing_device"] is mock_device + # import_as_vm must be flipped to False so future refreshes query Device + assert validation["import_as_vm"] is False + # A late-found cross-model match must never be import-ready: + # _refresh_existing_device re-asserts can_import=False/is_ready=False after + # recalculate_validation_status regardless of issues/fields state. + assert validation["can_import"] is False + assert validation["is_ready"] is False diff --git a/netbox_librenms_plugin/tests/test_coverage_device_fields.py b/netbox_librenms_plugin/tests/test_coverage_device_fields.py index 137c70e85..9e6247faa 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_fields.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_fields.py @@ -50,6 +50,7 @@ def test_no_librenms_id_returns_error(self): view._librenms_api.get_librenms_id.return_value = None mock_device = MagicMock() + mock_device.virtual_chassis = None with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, @@ -66,6 +67,7 @@ def test_get_device_info_failure(self): view._librenms_api.get_device_info.return_value = (False, None) mock_device = MagicMock() + mock_device.virtual_chassis = None with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, @@ -82,6 +84,7 @@ def test_get_device_info_empty_dict(self): view._librenms_api.get_device_info.return_value = (True, {}) mock_device = MagicMock() + mock_device.virtual_chassis = None with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, @@ -95,11 +98,15 @@ def test_get_device_info_empty_dict(self): def test_no_sysname_returns_warning(self): view = self._view() view._librenms_api.get_librenms_id.return_value = 42 - view._librenms_api.get_device_info.return_value = (True, {"sysName": None}) + view._librenms_api.get_device_info.return_value = (True, {"sysName": None, "hostname": None}) mock_device = MagicMock() + mock_device.virtual_chassis = None with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.resolve_naming_preferences", return_value=(True, False) + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -117,6 +124,14 @@ def test_save_success(self): mock_device.virtual_chassis = None with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.resolve_naming_preferences", + return_value=(True, False), + ), + patch( + "netbox_librenms_plugin.views.sync.device_fields._determine_device_name", + return_value="router1", + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect") as mock_redir, ): @@ -143,6 +158,14 @@ def test_save_validation_error_with_message_dict(self): with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.resolve_naming_preferences", + return_value=(True, False), + ), + patch( + "netbox_librenms_plugin.views.sync.device_fields._determine_device_name", + return_value="router1", + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -166,6 +189,14 @@ def test_save_integrity_error_without_message_dict(self): with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.resolve_naming_preferences", + return_value=(True, False), + ), + patch( + "netbox_librenms_plugin.views.sync.device_fields._determine_device_name", + return_value="router1", + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -393,6 +424,27 @@ def test_no_match_result(self): view.post(_make_request(), pk=1) mock_msg.error.assert_called_once() + def test_match_none_returns_ambiguous_error(self): + """match_librenms_hardware_to_device_type returns None β†’ ambiguous-match error path.""" + view = self._view() + view._librenms_api.get_librenms_id.return_value = 7 + view._librenms_api.get_device_info.return_value = (True, {"hardware": "Cisco 3750"}) + mock_device = MagicMock() + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.match_librenms_hardware_to_device_type", + return_value=None, + ), + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + ): + view.post(_make_request(), pk=1) + mock_msg.error.assert_called_once() + assert "Ambiguous" in mock_msg.error.call_args[0][1] + mock_device.full_clean.assert_not_called() + mock_device.save.assert_not_called() + def test_save_success(self): view = self._view() view._librenms_api.get_librenms_id.return_value = 7 @@ -517,13 +569,12 @@ def test_platform_does_not_exist(self): view._librenms_api.get_librenms_id.return_value = 3 view._librenms_api.get_device_info.return_value = (True, {"os": "ios"}) - mock_platform_cls = MagicMock() - mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) - mock_platform_cls.objects.get.side_effect = mock_platform_cls.DoesNotExist() - with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), - patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": False, "platform": None, "match_type": None}, + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -536,16 +587,16 @@ def test_save_success_with_old_platform(self): view._librenms_api.get_device_info.return_value = (True, {"os": "ios"}) mock_platform = MagicMock() - mock_platform_cls = MagicMock() - mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) - mock_platform_cls.objects.get.return_value = mock_platform mock_device = MagicMock() mock_device.platform = MagicMock() # old platform exists with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), - patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": True, "platform": mock_platform, "match_type": "exact"}, + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -562,16 +613,16 @@ def test_save_success_no_old_platform(self): view._librenms_api.get_device_info.return_value = (True, {"os": "ios"}) mock_platform = MagicMock() - mock_platform_cls = MagicMock() - mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) - mock_platform_cls.objects.get.return_value = mock_platform mock_device = MagicMock() mock_device.platform = None # no old platform with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), - patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": True, "platform": mock_platform, "match_type": "exact"}, + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -582,6 +633,49 @@ def test_save_success_no_old_platform(self): mock_device.full_clean.assert_called_once() mock_device.save.assert_called_once() + def test_save_success_via_platform_mapping(self): + """Sync button works when a PlatformMapping maps LibreNMS OS to a differently-named NetBox platform.""" + view = self._view() + view._librenms_api.get_librenms_id.return_value = 3 + view._librenms_api.get_device_info.return_value = (True, {"os": "junos"}) + + mock_platform = MagicMock() + mock_platform.__str__ = lambda self: "JunOS" + + mock_device = MagicMock() + mock_device.platform = None + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": True, "platform": mock_platform, "match_type": "mapping"}, + ), + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + ): + view.post(_make_request(), pk=1) + mock_msg.success.assert_called_once() + assert mock_device.platform is mock_platform + + def test_ambiguous_platform_returns_error(self): + view = self._view() + view._librenms_api.get_librenms_id.return_value = 3 + view._librenms_api.get_device_info.return_value = (True, {"os": "ios"}) + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": False, "platform": None, "match_type": "ambiguous"}, + ), + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + ): + view.post(_make_request(), pk=1) + mock_msg.error.assert_called_once() + assert "ambiguity" in mock_msg.error.call_args[0][1].lower() + def test_save_validation_error(self): from django.core.exceptions import ValidationError @@ -590,9 +684,6 @@ def test_save_validation_error(self): view._librenms_api.get_device_info.return_value = (True, {"os": "ios"}) mock_platform = MagicMock() - mock_platform_cls = MagicMock() - mock_platform_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) - mock_platform_cls.objects.get.return_value = mock_platform mock_device = MagicMock() mock_device.platform = None @@ -600,7 +691,10 @@ def test_save_validation_error(self): with ( patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), - patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch( + "netbox_librenms_plugin.views.sync.device_fields.find_matching_platform", + return_value={"found": True, "platform": mock_platform, "match_type": "exact"}, + ), patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), ): @@ -717,6 +811,40 @@ def test_success_no_manufacturer(self): assert mock_locked.platform == mock_platform_instance mock_locked.save.assert_called_once() + def test_platform_constructor_includes_slug(self): + """Platform must be constructed with slug=slugify(name) β€” regression for #279.""" + from django.utils.text import slugify + + view = self._view() + platform_name = "Cisco IOS-XE 17.x" + req = _make_request({"platform_name": platform_name, "manufacturer": ""}) + + mock_platform_cls = MagicMock() + mock_platform_cls.objects.filter.return_value.exists.return_value = False + mock_platform_instance = MagicMock() + mock_platform_cls.return_value = mock_platform_instance + + mock_device_cls = MagicMock() + mock_device_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_locked = MagicMock() + mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), + patch("netbox_librenms_plugin.views.sync.device_fields.messages"), + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + ): + view.post(req, pk=1) + + mock_platform_cls.assert_called_once_with( + name=platform_name, + slug=slugify(platform_name), + manufacturer=None, + ) + def test_platform_validation_error(self): from django.core.exceptions import ValidationError @@ -838,6 +966,153 @@ def test_integrity_error(self): mock_msg.error.assert_called_once() mock_txn.set_rollback.assert_called_once_with(True) + def _success_patches(self, platform_name="ios", librenms_os="ios", create_mapping="1"): + """Return (view, req, mock_platform_cls, mock_platform_instance, mock_device_cls, mock_locked).""" + view = self._view() + req = _make_request( + { + "platform_name": platform_name, + "manufacturer": "", + "librenms_os": librenms_os, + "create_mapping": create_mapping, + } + ) + mock_platform_cls = MagicMock() + mock_platform_cls.objects.filter.return_value.exists.return_value = False + mock_platform_instance = MagicMock() + mock_platform_cls.return_value = mock_platform_instance + mock_device_cls = MagicMock() + mock_device_cls.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_locked = MagicMock() + mock_device_cls.objects.select_for_update.return_value.get.return_value = mock_locked + return view, req, mock_platform_cls, mock_platform_instance, mock_device_cls, mock_locked + + def test_mapping_created_when_name_differs(self): + """A PlatformMapping is created when name differs from librenms_os and checkbox is on.""" + view, req, mock_platform_cls, mock_platform_instance, mock_device_cls, _ = self._success_patches( + platform_name="Cisco IOS", librenms_os="ios", create_mapping="1" + ) + mock_mapping_cls = MagicMock() + mock_mapping_instance = MagicMock() + mock_mapping_cls.return_value = mock_mapping_instance + mock_mapping_cls.objects.filter.return_value.first.return_value = None + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + patch("netbox_librenms_plugin.views.sync.device_fields.PlatformMapping", mock_mapping_cls), + ): + view.post(req, pk=1) + + mock_mapping_cls.assert_called_once_with(librenms_os="ios", netbox_platform=mock_platform_instance) + mock_mapping_instance.full_clean.assert_called_once() + mock_mapping_instance.save.assert_called_once() + success_msg = mock_msg.success.call_args[0][1] + assert "platform mapping" in success_msg + + def test_mapping_skipped_when_checkbox_off(self): + """No PlatformMapping is created when checkbox is unchecked.""" + view, req, mock_platform_cls, mock_platform_instance, mock_device_cls, _ = self._success_patches( + platform_name="Cisco IOS", librenms_os="ios", create_mapping="" + ) + mock_mapping_cls = MagicMock() + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), + patch("netbox_librenms_plugin.views.sync.device_fields.messages"), + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + patch("netbox_librenms_plugin.views.sync.device_fields.PlatformMapping", mock_mapping_cls), + ): + view.post(req, pk=1) + + mock_mapping_cls.assert_not_called() + + def test_mapping_skipped_when_already_exists(self): + """No duplicate PlatformMapping is created when one already exists for the OS.""" + view, req, mock_platform_cls, mock_platform_instance, mock_device_cls, _ = self._success_patches( + platform_name="Cisco IOS", librenms_os="ios", create_mapping="1" + ) + mock_mapping_cls = MagicMock() + mock_mapping_cls.objects.filter.return_value.first.return_value = MagicMock() # existing mapping + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction"), + patch("netbox_librenms_plugin.views.sync.device_fields.messages"), + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + patch("netbox_librenms_plugin.views.sync.device_fields.PlatformMapping", mock_mapping_cls), + ): + view.post(req, pk=1) + + mock_mapping_cls.assert_not_called() + + def test_required_object_permissions_include_platformmapping_when_create_mapping(self): + """When create_mapping is checked, ('add', PlatformMapping) is added to required_object_permissions + BEFORE require_all_permissions runs (so the authorization check sees it).""" + from netbox_librenms_plugin.models import PlatformMapping as RealPlatformMapping + + view, req, mock_platform_cls, _, mock_device_cls, _ = self._success_patches( + platform_name="Cisco IOS", librenms_os="ios", create_mapping="1" + ) + + captured = {} + + def fake_require(method): + captured["perms"] = view.required_object_permissions.get(method, []) + # Short-circuit by returning a sentinel response so post() exits early. + return MagicMock() + + view.require_all_permissions = fake_require + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.PlatformMapping", RealPlatformMapping), + ): + view.post(req, pk=1) + + assert ("add", RealPlatformMapping) in captured["perms"], ( + "Expected ('add', PlatformMapping) in required_object_permissions when create_mapping=True" + ) + + def test_required_object_permissions_exclude_platformmapping_when_no_create_mapping(self): + """When create_mapping is NOT checked, ('add', PlatformMapping) must NOT be added.""" + from netbox_librenms_plugin.models import PlatformMapping as RealPlatformMapping + + view, req, mock_platform_cls, _, mock_device_cls, _ = self._success_patches( + platform_name="Cisco IOS", librenms_os="ios", create_mapping="" + ) + + captured = {} + + def fake_require(method): + captured["perms"] = view.required_object_permissions.get(method, []) + return MagicMock() + + view.require_all_permissions = fake_require + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.device_fields.Platform", mock_platform_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.Device", mock_device_cls), + patch("netbox_librenms_plugin.views.sync.device_fields.PlatformMapping", RealPlatformMapping), + ): + view.post(req, pk=1) + + assert ("add", RealPlatformMapping) not in captured["perms"], ( + "Did not expect ('add', PlatformMapping) when create_mapping is unchecked" + ) + # --------------------------------------------------------------------------- # AssignVCSerialView diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 2c2848bdf..22b5c86fe 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -234,7 +234,7 @@ def _run_validate(self, libre_device, patches_overrides=None, **kwargs): ), patch( "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", - return_value={"is_stack": False, "member_count": 0, "members": []}, + return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None}, ), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole", mock_role), patch("netbox_librenms_plugin.import_utils.device_operations.Cluster", mock_cluster), @@ -533,7 +533,7 @@ def _patch_all_db(self): ), patch( "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", - return_value={"is_stack": False, "member_count": 0, "members": []}, + return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None}, ), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole", mock_device_role), patch("netbox_librenms_plugin.import_utils.device_operations.Cluster", mock_cluster), @@ -590,16 +590,22 @@ def test_vm_import_uses_correct_model(self): api = self._make_api() patches = self._patch_all_db() + mock_vm_cls = MagicMock() + mock_vm_cls.objects.filter.return_value.first.return_value = None try: for p in patches: p.start() - result = validate_device_for_import(libre_device, import_as_vm=True, api=api) + # Override with a controlled mock so we can assert the VM model was consulted + with patch("virtualization.models.VirtualMachine", mock_vm_cls): + result = validate_device_for_import(libre_device, import_as_vm=True, api=api) finally: for p in patches: p.stop() assert result is not None assert result.get("import_as_vm") is True + # Verify VirtualMachine model (not Device) was used for the hostname lookup + mock_vm_cls.objects.filter.assert_called() def test_existing_device_detected(self): """When device with same librenms_id exists, sets existing_device in result.""" @@ -779,7 +785,7 @@ def _start_patches(self, extra_patches=None): ), patch( "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", - return_value={"is_stack": False, "member_count": 0, "members": []}, + return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None}, ), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole", mock_role), patch("netbox_librenms_plugin.import_utils.device_operations.Cluster", mock_cluster), @@ -896,6 +902,8 @@ def test_vc_detection_called_for_device_with_api(self): self._stop_patches(patches) assert result["virtual_chassis"] is not None + assert result["virtual_chassis"]["is_stack"] is True + assert result["virtual_chassis"]["member_count"] == 2 mock_get_vc.assert_called_once() mock_update_vc.assert_called_once() @@ -1055,7 +1063,7 @@ def _get_patches(self): ), patch( "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", - return_value={"is_stack": False, "member_count": 0, "members": []}, + return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None}, ), patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole", mock_role), patch("netbox_librenms_plugin.import_utils.device_operations.Cluster", mock_cluster), @@ -1397,6 +1405,12 @@ def test_manual_mappings_are_applied(self, MockAPI): mock_new_device.full_clean.assert_called_once() mock_new_device.save.assert_called_once() mock_set_id.assert_called_once() + # Verify Device was constructed with the resolved site, device_type, and role + mock_device_cls.assert_called_once() + call_kwargs = mock_device_cls.call_args[1] + assert call_kwargs["site"] is mock_site + assert call_kwargs["device_type"] is mock_dt + assert call_kwargs["role"] is mock_role class TestImportSingleDeviceMoreEdgeCases: diff --git a/netbox_librenms_plugin/tests/test_coverage_devices.py b/netbox_librenms_plugin/tests/test_coverage_devices.py new file mode 100644 index 000000000..dc5ed98b3 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_coverage_devices.py @@ -0,0 +1,1057 @@ +"""Coverage tests for views/object_sync/devices.py.""" + +from unittest.mock import MagicMock, patch + + +def _make_device_view(): + """Create a DeviceLibreNMSSyncView instance bypassing __init__.""" + from netbox_librenms_plugin.views.object_sync.devices import DeviceLibreNMSSyncView + + view = object.__new__(DeviceLibreNMSSyncView) + view.request = MagicMock() + view.request.path = "/dcim/devices/1/librenms-sync/" + view.kwargs = {} + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view._librenms_api.cache_timeout = 300 + return view + + +def _make_interface_view(): + """Create a DeviceInterfaceTableView instance bypassing __init__.""" + from netbox_librenms_plugin.views.object_sync.devices import DeviceInterfaceTableView + + view = object.__new__(DeviceInterfaceTableView) + view.request = MagicMock() + view.request.path = "/dcim/devices/1/librenms-sync/" + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + +class TestDeviceLibreNMSSyncViewContextMethods: + """Tests for DeviceLibreNMSSyncView context delegation.""" + + def test_get_interface_context_delegates_to_interface_view(self): + """get_interface_context() creates DeviceInterfaceTableView, copies request, and calls get_context_data.""" + + view = _make_device_view() + request = MagicMock() + obj = MagicMock() + + mock_ctx = {"interfaces": []} + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceInterfaceTableView.get_context_data", + autospec=True, + return_value=mock_ctx, + ) as mock_get_context: + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_interface_name_field", return_value="ifName" + ): + result = view.get_interface_context(request, obj) + assert result == mock_ctx + assert mock_get_context.called + child_instance = mock_get_context.call_args[0][0] + # Identity check: the child view must store a *copy* of the request, + # not the original β€” equality (==) would silently pass even if the + # original were reused (MagicMock instances compare equal). + assert child_instance.request is not request + assert mock_get_context.call_args[0][1] is request + assert mock_get_context.call_args[0][2] is obj + + def test_get_cable_context_delegates_to_cable_view(self): + """get_cable_context() creates DeviceCableTableView, copies request, and calls get_context_data.""" + + view = _make_device_view() + request = MagicMock() + obj = MagicMock() + + mock_ctx = {"cables": []} + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceCableTableView.get_context_data", + autospec=True, + return_value=mock_ctx, + ) as mock_get_context: + result = view.get_cable_context(request, obj) + assert result == mock_ctx + assert mock_get_context.called + child_instance = mock_get_context.call_args[0][0] + assert child_instance.request is not request + assert mock_get_context.call_args[0][1] is request + assert mock_get_context.call_args[0][2] is obj + + def test_get_ip_context_delegates_to_ip_view(self): + """get_ip_context() creates DeviceIPAddressTableView and calls get_context_data with request and obj.""" + + view = _make_device_view() + request = MagicMock() + obj = MagicMock() + + mock_ctx = {"ips": []} + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceIPAddressTableView.get_context_data", + autospec=True, + return_value=mock_ctx, + ) as mock_get_context: + result = view.get_ip_context(request, obj) + assert result == mock_ctx + assert mock_get_context.called + assert mock_get_context.call_args[0][1] is request + assert mock_get_context.call_args[0][2] is obj + child_instance = mock_get_context.call_args[0][0] + assert child_instance.request is not request + + def test_get_vlan_context_delegates_to_vlan_view(self): + """get_vlan_context() creates DeviceVLANTableView, copies request, and calls get_vlan_context.""" + + view = _make_device_view() + request = MagicMock() + obj = MagicMock() + + mock_ctx = {"vlans": []} + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceVLANTableView.get_vlan_context", + autospec=True, + return_value=mock_ctx, + ) as mock_get_context: + result = view.get_vlan_context(request, obj) + assert result == mock_ctx + assert mock_get_context.called + child_instance = mock_get_context.call_args[0][0] + assert child_instance.request is not request + assert mock_get_context.call_args[0][1] is request + assert mock_get_context.call_args[0][2] is obj + + def test_get_module_context_delegates_to_module_view(self): + """get_module_context() creates DeviceModuleTableView, copies request, and calls get_context_data.""" + + view = _make_device_view() + request = MagicMock() + obj = MagicMock() + + mock_ctx = {"modules": []} + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView.get_context_data", + autospec=True, + return_value=mock_ctx, + ) as mock_get_context: + result = view.get_module_context(request, obj) + assert result == mock_ctx + assert mock_get_context.called + child_instance = mock_get_context.call_args[0][0] + assert child_instance.request is not request + assert mock_get_context.call_args[0][1] is request + assert mock_get_context.call_args[0][2] is obj + + +class TestDeviceInterfaceTableView: + """Tests for DeviceInterfaceTableView.""" + + def test_get_interfaces_returns_all_interfaces(self): + """get_interfaces() returns obj.interfaces.all().""" + view = _make_interface_view() + obj = MagicMock() + mock_qs = MagicMock() + obj.interfaces.all.return_value = mock_qs + + result = view.get_interfaces(obj) + assert result is mock_qs + obj.interfaces.all.assert_called_once() + + def test_get_redirect_url_returns_device_url(self): + """get_redirect_url() returns the device interface sync URL.""" + view = _make_interface_view() + obj = MagicMock() + obj.pk = 42 + + with patch("netbox_librenms_plugin.views.object_sync.devices.reverse") as mock_reverse: + mock_reverse.return_value = "/dcim/devices/42/interface-sync/" + result = view.get_redirect_url(obj) + mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:device_interface_sync", kwargs={"pk": 42}) + assert result == "/dcim/devices/42/interface-sync/" + + def test_get_table_returns_vc_table_for_vc_device(self): + """get_table() returns VCInterfaceTable when device has virtual_chassis.""" + + view = _make_interface_view() + obj = MagicMock() + obj.virtual_chassis = MagicMock() # Has VC + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + + with patch("netbox_librenms_plugin.views.object_sync.devices.VCInterfaceTable") as mock_vc_table: + mock_table = MagicMock() + mock_vc_table.return_value = mock_table + result = view.get_table([], obj, "ifName", vlan_groups=[]) + + mock_vc_table.assert_called_once_with( + [], device=obj, interface_name_field="ifName", vlan_groups=[], server_key="default" + ) + assert result is mock_table + + def test_get_table_returns_librenms_table_for_non_vc_device(self): + """get_table() returns LibreNMSInterfaceTable when no virtual_chassis.""" + view = _make_interface_view() + obj = MagicMock() + obj.virtual_chassis = None # No VC + + with patch("netbox_librenms_plugin.views.object_sync.devices.LibreNMSInterfaceTable") as mock_table_cls: + mock_table = MagicMock() + mock_table_cls.return_value = mock_table + result = view.get_table([], obj, "ifName") + + mock_table_cls.assert_called_once_with( + [], device=obj, interface_name_field="ifName", vlan_groups=None, server_key="default" + ) + assert result is mock_table + + def test_get_table_sets_htmx_url(self): + """get_table() sets htmx_url on the returned table.""" + view = _make_interface_view() + view.request.path = "/dcim/devices/1/librenms-sync/" + obj = MagicMock() + obj.virtual_chassis = None + + with patch("netbox_librenms_plugin.views.object_sync.devices.LibreNMSInterfaceTable") as mock_table_cls: + mock_table = MagicMock() + mock_table_cls.return_value = mock_table + view.get_table([], obj, "ifName") + + assert mock_table.htmx_url == "/dcim/devices/1/librenms-sync/?tab=interfaces&server_key=default" + + +class TestSingleInterfaceVerifyView: + """Tests for SingleInterfaceVerifyView.""" + + def _make_view(self): + from unittest.mock import MagicMock + + from netbox_librenms_plugin.views.object_sync.devices import SingleInterfaceVerifyView + + view = object.__new__(SingleInterfaceVerifyView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_returns_400_when_no_device_id(self): + """Returns 400 JSON error when no device_id in request body.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"interface_name": "eth0"}).encode() + + response = view.post(request) + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_404_when_no_cached_data(self): + """Returns 404 when cached ports data not found.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "interface_name": "eth0"}).encode() + + mock_device = MagicMock() + mock_device.virtual_chassis = None + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", return_value=mock_device + ): + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.get.return_value = None + with patch.object(view, "get_cache_key", return_value="test_key"): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 404 + + def test_returns_404_when_interface_not_in_cache(self): + """Returns 404 when interface not found in cached ports data.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "interface_name": "eth99"}).encode() + + mock_device = MagicMock() + mock_device.virtual_chassis = None + + cached_data = {"ports": [{"ifName": "eth0", "speed": 1000}]} + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", return_value=mock_device + ): + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.get.return_value = cached_data + with patch.object(view, "get_cache_key", return_value="test_key"): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 404 + + def test_returns_success_when_interface_found(self): + """Returns success JSON with formatted_row when interface found in cache.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "interface_name": "eth0", "interface_name_field": "ifName"}).encode() + + mock_device = MagicMock() + mock_device.virtual_chassis = None + + port_data = {"ifName": "eth0", "speed": 1000} + cached_data = {"ports": [port_data]} + + mock_table = MagicMock() + mock_table.format_interface_data.return_value = "row" + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", return_value=mock_device + ): + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.get.return_value = cached_data + with patch.object(view, "get_cache_key", return_value="test_key"): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.LibreNMSInterfaceTable", + return_value=mock_table, + ): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "success" + assert "formatted_row" in data + + def test_non_vc_device_skips_sync_device_lookup(self): + """For non-VC devices, get_librenms_sync_device is not called; selected_device used directly.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "interface_name": "eth0", "interface_name_field": "ifName"}).encode() + + mock_device = MagicMock() + mock_device.virtual_chassis = None + port_data = {"ifName": "eth0", "speed": 1000} + cached_data = {"ports": [port_data]} + mock_table = MagicMock() + mock_table.format_interface_data.return_value = "row" + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", return_value=None + ) as mock_get_sync_device: + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.get.return_value = cached_data + with patch.object(view, "get_cache_key", return_value="test_key") as get_cache_key_mock: + with patch( + "netbox_librenms_plugin.views.object_sync.devices.LibreNMSInterfaceTable", + return_value=mock_table, + ): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + # Non-VC device: get_librenms_sync_device should NOT be called + mock_get_sync_device.assert_not_called() + # The cache key builder was called with mock_device directly + assert get_cache_key_mock.call_args[0][0] is mock_device + + +class TestSingleModuleVerifyView: + """Tests for SingleModuleVerifyView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import SingleModuleVerifyView + + view = object.__new__(SingleModuleVerifyView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view.has_write_permission = MagicMock(return_value=True) + view.require_object_permissions_json = MagicMock(return_value=None) + view.get_cache_key = MagicMock(return_value="test_key") + return view + + def test_success_propagates_can_change_interface_to_verify_table(self): + """Verify-row table keeps Update Interface available for users with change permission.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "ent_physical_index": 10, "server_key": "default"}).encode() + request.user.has_perm = MagicMock( + side_effect=lambda perm: ( + perm + in { + "dcim.add_module", + "dcim.change_module", + "dcim.change_interface", + "dcim.delete_module", + "dcim.add_modulebaytemplate", + "dcim.add_moduletype", + "netbox_librenms_plugin.add_carrierautoinstallrule", + "netbox_librenms_plugin.add_modulebaymapping", + "netbox_librenms_plugin.add_moduletypemapping", + } + ) + ) + + selected_device = MagicMock() + selected_device.virtual_chassis = None + selected_device.device_type = MagicMock() + selected_device.device_type.manufacturer = MagicMock() + inventory_data = [{"entPhysicalIndex": 10, "entPhysicalContainedIn": 0, "entPhysicalName": "Module 1"}] + row = {"ent_physical_index": 10, "depth": 0, "status": "Installed"} + + mock_table = MagicMock() + mock_table.format_module_data.return_value = "row" + + with ( + patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=selected_device), + patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache, + patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])), + patch("netbox_librenms_plugin.utils.get_enabled_ignore_rules", return_value=[]), + patch("netbox_librenms_plugin.utils.preload_normalization_rules", return_value={}), + patch( + "netbox_librenms_plugin.views.object_sync.devices.LibreNMSModuleTable", return_value=mock_table + ) as mock_table_cls, + patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView._get_module_types", + return_value={}, + ), + patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView._find_transparent_indices", + return_value=set(), + ), + patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView._collect_top_items", + return_value=inventory_data, + ), + patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView._build_table_rows_for_member", + return_value=[row], + ), + patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView._detect_serial_conflicts", + return_value=None, + ), + ): + mock_cache.get.return_value = {"inventory": inventory_data} + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + mock_table_cls.assert_called_once_with( + [], + device=selected_device, + server_key="default", + has_write_permission=True, + can_add_module=True, + can_change_module=True, + can_change_interface=True, + can_delete_module=True, + can_add_module_bay_template=True, + can_add_module_type=True, + can_add_carrier_rule=True, + can_add_module_bay_mapping=True, + can_add_module_type_mapping=True, + ) + + +class TestSingleVlanGroupVerifyView: + """Tests for SingleVlanGroupVerifyView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import SingleVlanGroupVerifyView + + view = object.__new__(SingleVlanGroupVerifyView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_returns_400_when_no_device_id(self): + """Returns 400 when no device_id provided.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vid": "10"}).encode() + + response = view.post(request) + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_400_when_no_vid(self): + """Returns 400 when no vid provided.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1}).encode() + + response = view.post(request) + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_400_when_invalid_vid(self): + """Returns 400 when vid is not a valid integer.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "vid": "notanumber"}).encode() + + mock_device = MagicMock() + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_success_with_vlan_group(self): + """Returns success with css_class when valid vlan_group_id provided.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps( + { + "device_id": 1, + "vid": "10", + "vlan_group_id": "5", + "vlan_type": "U", + } + ).encode() + + mock_device = MagicMock() + mock_netbox_iface = MagicMock() + mock_netbox_iface.untagged_vlan = None + mock_netbox_iface.tagged_vlans.all.return_value = [] + mock_device.interfaces.filter.return_value.first.return_value = mock_netbox_iface + + mock_vlan_group = MagicMock() + mock_vlan_qs = MagicMock() + mock_vlan_qs.values_list.return_value = [10] + mock_global_qs = MagicMock() + mock_global_qs.values_list.return_value = [] + + mock_vlan_model = MagicMock() + mock_vlan_model.objects.filter.return_value = mock_vlan_qs + mock_vlan_group_model = MagicMock() + mock_vlan_group_model.objects.filter.return_value = mock_global_qs + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404") as mock_get_obj: + mock_get_obj.side_effect = [mock_device, mock_vlan_group] + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_untagged_vlan_css_class", + return_value="text-success", + ): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_tagged_vlan_css_class", + return_value="text-success", + ): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_missing_vlan_warning", return_value="" + ): + # Patch the VLAN/VLANGroup imports inside the method + mock_ipam = MagicMock() + mock_ipam.VLAN = mock_vlan_model + mock_ipam.VLANGroup = mock_vlan_group_model + + with patch.dict("sys.modules", {"ipam.models": mock_ipam}): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "success" + assert data["css_class"] == "text-success" + assert data["is_missing"] is False + + def test_returns_success_without_vlan_group(self): + """Returns success with global VLANs when no vlan_group_id provided.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps( + { + "device_id": 1, + "vid": "100", + "vlan_type": "T", + } + ).encode() + + mock_device = MagicMock() + mock_device.interfaces.filter.return_value.first.return_value = None + + mock_vlan_qs = MagicMock() + mock_vlan_qs.values_list.return_value = [] + mock_vlan_model = MagicMock() + mock_vlan_model.objects.filter.return_value = mock_vlan_qs + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_untagged_vlan_css_class", + return_value="text-danger", + ): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_tagged_vlan_css_class", + return_value="text-danger", + ): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_missing_vlan_warning", return_value="" + ): + mock_ipam = MagicMock() + mock_ipam.VLAN = mock_vlan_model + mock_ipam.VLANGroup = MagicMock() + + with patch.dict("sys.modules", {"ipam.models": mock_ipam}): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "success" + assert data["css_class"] == "text-danger" + assert data["is_missing"] is True # vid=100 not in empty group VID list + + def test_existing_interface_with_untagged_and_tagged_vlans(self): + """Covers NetBox VLAN extraction branches for untagged and tagged VLANs.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps( + { + "device_id": 1, + "vid": "10", + "vlan_group_id": "5", + "vlan_type": "T", + "interface_name": "eth0", + } + ).encode() + + untagged = MagicMock() + untagged.vid = 1 + untagged.group_id = 9 + tagged = MagicMock() + tagged.vid = 10 + tagged.group_id = 5 + + mock_iface = MagicMock() + mock_iface.untagged_vlan = untagged + mock_iface.tagged_vlans.all.return_value = [tagged] + mock_device = MagicMock() + mock_device.interfaces.filter.return_value.first.return_value = mock_iface + + mock_vlan_group = MagicMock() + mock_group_qs = MagicMock() + mock_group_qs.values_list.return_value = [10] + mock_global_qs = MagicMock() + mock_global_qs.values_list.return_value = [] + mock_vlan_model = MagicMock() + mock_vlan_model.objects.filter.side_effect = [mock_group_qs, mock_global_qs] + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404") as mock_get_obj: + mock_get_obj.side_effect = [mock_device, mock_vlan_group] + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_tagged_vlan_css_class", + return_value="text-success", + ): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_missing_vlan_warning", return_value="" + ): + mock_ipam = MagicMock() + mock_ipam.VLAN = mock_vlan_model + mock_ipam.VLANGroup = MagicMock() + with patch.dict("sys.modules", {"ipam.models": mock_ipam}): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + + def test_render_vlans_cell_returns_dash_for_empty_values(self): + """Empty VLAN inputs render em dash placeholder.""" + view = self._make_view() + assert view._render_vlans_cell(None, [], [], False, None, set()) == "β€”" + + +class TestVerifyVlanSyncGroupView: + """Tests for VerifyVlanSyncGroupView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import VerifyVlanSyncGroupView + + view = object.__new__(VerifyVlanSyncGroupView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_returns_400_when_no_vid(self): + """Returns 400 when no vid provided.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vlan_group_id": "1"}).encode() + + response = view.post(request) + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_400_when_invalid_vid(self): + """Returns 400 when vid is not a valid integer.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vid": "badvalue"}).encode() + + response = view.post(request) + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_returns_success_with_vlan_group(self): + """Returns success with exists_in_netbox and css_class for vlan_group_id path.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vid": "10", "vlan_group_id": "3", "name": "vlan10"}).encode() + + mock_vlan = MagicMock() + mock_vlan.name = "vlan10" + mock_vlan_group = MagicMock() + + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404") as mock_get_obj: + mock_get_obj.return_value = mock_vlan_group + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_vlan_sync_css_class", return_value="text-success" + ): + mock_ipam = MagicMock() + mock_vlan_model = MagicMock() + mock_vlan_model.objects.filter.return_value.first.return_value = mock_vlan + mock_ipam.VLAN = mock_vlan_model + mock_ipam.VLANGroup = MagicMock() + + with patch.dict("sys.modules", {"ipam.models": mock_ipam}): + response = view.post(request) + + assert isinstance(response, JsonResponse) + data = json.loads(response.content) + assert response.status_code == 200 + assert data["status"] == "success" + assert "exists_in_netbox" in data + assert data["css_class"] == "text-success" + + def test_returns_success_without_vlan_group(self): + """Returns success with global VLAN lookup when no vlan_group_id.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vid": "20", "name": "vlan20"}).encode() + + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_vlan_sync_css_class", return_value="text-danger" + ): + mock_ipam = MagicMock() + mock_vlan_model = MagicMock() + mock_vlan_model.objects.filter.return_value.first.return_value = None + mock_ipam.VLAN = mock_vlan_model + mock_ipam.VLANGroup = MagicMock() + + with patch.dict("sys.modules", {"ipam.models": mock_ipam}): + response = view.post(request) + + assert isinstance(response, JsonResponse) + data = json.loads(response.content) + assert response.status_code == 200 + assert data["status"] == "success" + assert data["exists_in_netbox"] is False + assert data["css_class"] == "text-danger" + + +class TestSaveVlanGroupOverridesView: + """Tests for SaveVlanGroupOverridesView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import SaveVlanGroupOverridesView + + view = object.__new__(SaveVlanGroupOverridesView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + return view + + def test_returns_error_when_no_device_id(self): + """Returns 400 error when no device_id in request.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"vid_group_map": {}}).encode() + + with patch.object(view, "require_write_permission_json", return_value=None): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_requires_write_permission(self): + """Returns error response when user lacks write permission.""" + import json + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1}).encode() + + error_response = MagicMock() + with patch.object(view, "require_write_permission_json", return_value=error_response): + result = view.post(request) + + assert result is error_response + + def test_returns_error_when_no_cached_ports(self): + """Returns 400 when ports cache TTL is zero (no cached data).""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "vid_group_map": {"10": "5"}}).encode() + + mock_device = MagicMock() + + with patch.object(view, "require_write_permission_json", return_value=None): + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", + return_value=mock_device, + ): + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.ttl.return_value = 0 + with patch.object(view, "get_cache_key", return_value="ports_key"): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 400 + + def test_saves_overrides_to_cache(self): + """Successfully saves VLAN group overrides to cache.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps( + { + "device_id": 1, + "vid_group_map": {"10": "5", "20": "5"}, + "server_key": "default", + } + ).encode() + + mock_device = MagicMock() + + with patch.object(view, "require_write_permission_json", return_value=None): + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", + return_value=mock_device, + ): + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.ttl.return_value = 300 + mock_cache.get.return_value = {} + with patch.object(view, "get_cache_key", return_value="ports_key"): + with patch.object(view, "get_vlan_overrides_key", return_value="vlan_overrides_key"): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "success" + mock_cache.set.assert_called_once() + + def test_save_overrides_uses_device_when_sync_device_none(self): + """If VC sync-device resolution fails, fallback uses original device.""" + import json + + from django.http import JsonResponse + + view = self._make_view() + request = MagicMock() + request.body = json.dumps({"device_id": 1, "vid_group_map": {"10": "5"}, "server_key": "default"}).encode() + mock_device = MagicMock() + + with patch.object(view, "require_write_permission_json", return_value=None): + with patch("netbox_librenms_plugin.views.object_sync.devices.get_object_or_404", return_value=mock_device): + with patch( + "netbox_librenms_plugin.views.object_sync.devices.get_librenms_sync_device", return_value=None + ) as mock_get_sync_device: + with patch("netbox_librenms_plugin.views.object_sync.devices.cache") as mock_cache: + mock_cache.ttl.return_value = 300 + mock_cache.get.return_value = {} + with patch.object(view, "get_cache_key", return_value="ports_key"): + with patch.object(view, "get_vlan_overrides_key", return_value="vlan_overrides_key"): + response = view.post(request) + + assert isinstance(response, JsonResponse) + assert response.status_code == 200 + # Verify fallback: get_librenms_sync_device was called with the selected device + mock_get_sync_device.assert_called_once() + assert mock_get_sync_device.call_args[0][0] is mock_device + + +class TestDeviceCableTableView: + """Tests for DeviceCableTableView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import DeviceCableTableView + + view = object.__new__(DeviceCableTableView) + view._librenms_api = MagicMock() + return view + + def test_get_table_returns_vc_cable_table_for_vc_device(self): + """get_table() returns VCCableTable when device has virtual_chassis.""" + view = self._make_view() + obj = MagicMock() + obj.virtual_chassis = MagicMock() + + with patch("netbox_librenms_plugin.views.object_sync.devices.VCCableTable") as mock_vc_table: + mock_table = MagicMock() + mock_vc_table.return_value = mock_table + result = view.get_table([], obj) + + assert result is mock_table + mock_vc_table.assert_called_once_with([], device=obj) + + def test_get_table_returns_librenms_cable_table_for_non_vc_device(self): + """get_table() returns LibreNMSCableTable when no virtual_chassis.""" + view = self._make_view() + obj = MagicMock() + obj.virtual_chassis = None + + with patch("netbox_librenms_plugin.views.object_sync.devices.LibreNMSCableTable") as mock_cable_table: + mock_table = MagicMock() + mock_cable_table.return_value = mock_table + result = view.get_table([], obj) + + assert result is mock_table + mock_cable_table.assert_called_once_with([], device=obj) + + +class TestDeviceModuleTableView: + """Tests for DeviceModuleTableView.""" + + def _make_view(self): + from netbox_librenms_plugin.views.object_sync.devices import DeviceModuleTableView + + view = object.__new__(DeviceModuleTableView) + view.request = MagicMock() + view.request.path = "/dcim/devices/1/librenms-sync/" + view._librenms_api = MagicMock() + view._librenms_api.server_key = "prod-server" + view.has_write_permission = MagicMock(return_value=True) + view.request.user.has_perm = MagicMock( + side_effect=lambda p: ( + p + in { + "dcim.add_module", + "dcim.change_module", + "dcim.change_interface", + "dcim.delete_module", + "dcim.add_modulebaytemplate", + "dcim.add_moduletype", + "netbox_librenms_plugin.add_carrierautoinstallrule", + "netbox_librenms_plugin.add_modulebaymapping", + "netbox_librenms_plugin.add_moduletypemapping", + } + ) + ) + return view + + def test_get_table_returns_librenms_module_table(self): + """get_table() returns LibreNMSModuleTable with device and server_key.""" + view = self._make_view() + obj = MagicMock() + obj.virtual_chassis = None + + with patch("netbox_librenms_plugin.views.object_sync.devices.LibreNMSModuleTable") as mock_table_cls: + mock_table = MagicMock() + mock_table_cls.return_value = mock_table + result = view.get_table([], obj) + + mock_table_cls.assert_called_once_with( + [], + device=obj, + server_key="prod-server", + has_write_permission=True, + can_add_module=True, + can_change_module=True, + can_change_interface=True, + can_delete_module=True, + can_add_module_bay_template=True, + can_add_module_type=True, + can_add_carrier_rule=True, + can_add_module_bay_mapping=True, + can_add_module_type_mapping=True, + ) + assert result is mock_table + + def test_get_table_sets_htmx_url(self): + """get_table() sets htmx_url with modules tab.""" + view = self._make_view() + obj = MagicMock() + obj.virtual_chassis = None + + with patch("netbox_librenms_plugin.views.object_sync.devices.LibreNMSModuleTable") as mock_table_cls: + mock_table = MagicMock() + mock_table_cls.return_value = mock_table + view.get_table([], obj) + + assert mock_table.htmx_url == "/dcim/devices/1/librenms-sync/?tab=modules&server_key=prod-server" diff --git a/netbox_librenms_plugin/tests/test_coverage_filters.py b/netbox_librenms_plugin/tests/test_coverage_filters.py index 2b4250ba5..deac03ff5 100644 --- a/netbox_librenms_plugin/tests/test_coverage_filters.py +++ b/netbox_librenms_plugin/tests/test_coverage_filters.py @@ -766,3 +766,5 @@ def test_cache_key_uses_api_server_key(self, mock_cache): assert mock_cache.set.call_count == 2 keys = [call.args[0] for call in mock_cache.set.call_args_list] assert keys[0] != keys[1] + assert any("server1" in k for k in keys) + assert any("server2" in k for k in keys) diff --git a/netbox_librenms_plugin/tests/test_coverage_forms.py b/netbox_librenms_plugin/tests/test_coverage_forms.py index 30b38dd38..5cff3fbf6 100644 --- a/netbox_librenms_plugin/tests/test_coverage_forms.py +++ b/netbox_librenms_plugin/tests/test_coverage_forms.py @@ -59,3 +59,190 @@ def test_pagination_param_does_not_inject_background_job(self): """Auxiliary params like 'page' must not trigger background-job default injection.""" form = self._make_form({"page": "2"}) assert form.data.get("use_background_job") is None + + +class TestPollerGroupCacheKeyServerScoped: + """Finding 6: verify the poller-group cache key is scoped by server_key.""" + + def test_cache_hit_returns_cached_choices(self): + """Cache lookup uses the server-scoped key; hit returns cached value without API call.""" + from unittest.mock import MagicMock, patch + + mock_api = MagicMock() + mock_api.server_key = "prod" + cached = [("0", "Default (0)"), ("1", "Group1 (1)")] + + with ( + patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI", return_value=mock_api), + patch("django.core.cache.cache") as mock_cache, + ): + mock_cache.get.return_value = cached + from netbox_librenms_plugin.forms import _get_librenms_poller_group_choices + + result = _get_librenms_poller_group_choices() + + expected_key = f"librenms_poller_group_choices_{mock_api.server_key}" + mock_cache.get.assert_called_once_with(expected_key) + assert result is cached + + def test_cache_miss_calls_api_and_stores_server_scoped_key(self): + """Cache miss fetches from API and stores under the server-scoped key.""" + from unittest.mock import MagicMock, patch + + mock_api = MagicMock() + mock_api.server_key = "staging" + mock_api.cache_timeout = 60 + mock_api.get_poller_groups.return_value = (True, [{"id": 1, "group_name": "G1", "descr": ""}]) + + with ( + patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI", return_value=mock_api), + patch("django.core.cache.cache") as mock_cache, + ): + mock_cache.get.return_value = None # cache miss + from netbox_librenms_plugin.forms import _get_librenms_poller_group_choices + + _get_librenms_poller_group_choices() + + expected_key = f"librenms_poller_group_choices_{mock_api.server_key}" + set_call_key = mock_cache.set.call_args[0][0] + assert set_call_key == expected_key + + def test_api_init_failure_returns_defaults_without_cache_lookup(self): + """If LibreNMSAPI() raises, return defaults β€” never read the legacy unscoped key.""" + from unittest.mock import patch + + with ( + patch( + "netbox_librenms_plugin.librenms_api.LibreNMSAPI", + side_effect=RuntimeError("no config"), + ), + patch("django.core.cache.cache") as mock_cache, + ): + from netbox_librenms_plugin.forms import _get_librenms_poller_group_choices + + result = _get_librenms_poller_group_choices() + + assert result == [("0", "Default (0)")] + mock_cache.get.assert_not_called() + mock_cache.set.assert_not_called() + + +class TestQueryDictNotMutated: + """Finding 7: LibreNMSImportFilterForm must not mutate the original QueryDict.""" + + def test_querydict_original_not_modified(self): + """When a QueryDict without use_background_job is passed, the original must be unchanged.""" + from django.http import QueryDict + from unittest.mock import patch + + # An empty QueryDict has no fields β€” should trigger auto-inject of use_background_job, + # but only on the copy, not on the original. + qd = QueryDict("") + assert qd.get("use_background_job") is None + + with ( + patch("netbox_librenms_plugin.forms.LibreNMSImportFilterForm._populate_librenms_locations"), + ): + from netbox_librenms_plugin.forms import LibreNMSImportFilterForm + + LibreNMSImportFilterForm(qd) + + # Original QueryDict must remain unmodified + assert qd.get("use_background_job") is None + + +class TestDeviceImportConfigFormInitialValues: + """Finding 8: DeviceImportConfigForm sets/skips initial values correctly.""" + + def _make_form(self, libre_device=None, validation=None): + from unittest.mock import patch + + with ( + patch("dcim.models.Platform"), + patch("netbox_librenms_plugin.forms.Site"), + patch("netbox_librenms_plugin.forms.DeviceType"), + patch("netbox_librenms_plugin.forms.DeviceRole"), + ): + from netbox_librenms_plugin.forms import DeviceImportConfigForm + + return DeviceImportConfigForm( + libre_device=libre_device or {}, + validation=validation or {}, + ) + + def test_empty_validation_dict_no_initial_set(self): + """With empty libre_device and validation, no field initials should be set by the form.""" + form = self._make_form(libre_device={}, validation={}) + + # Fields populated from libre_device should have blank/None initials when no data given + assert form.fields["hostname"].initial in (None, "") + assert form.fields["hardware"].initial in (None, "") + + def test_libre_device_sets_initial_hostname(self): + """libre_device dict populates hostname and hardware initial values.""" + libre_device = { + "device_id": 5, + "hostname": "sw01", + "hardware": "Cisco 3850", + "location": "NYC", + } + + form = self._make_form(libre_device=libre_device, validation={}) + + assert form.fields["hostname"].initial == "sw01" + assert form.fields["hardware"].initial == "Cisco 3850" + assert form.fields["device_id"].initial == 5 + + +class TestAddToLibreSNMPV3Validation: + """Tests for SNMPv3 form conditional field requirements based on authlevel.""" + + _BASE_DATA = { + "hostname": "10.0.0.1", + "snmp_version": "v3", + "authname": "admin", + } + + def _make_form(self, extra): + from unittest.mock import patch + + from netbox_librenms_plugin.forms import AddToLibreSNMPV3 + + data = {**self._BASE_DATA, **extra} + with patch( + "netbox_librenms_plugin.forms._get_librenms_poller_group_choices", + return_value=[("0", "Default (0)")], + ): + return AddToLibreSNMPV3(data=data) + + def test_no_auth_no_priv_valid_without_auth_fields(self): + form = self._make_form({"authlevel": "noAuthNoPriv"}) + assert form.is_valid(), form.errors + + def test_auth_no_priv_requires_auth_fields(self): + form = self._make_form({"authlevel": "authNoPriv"}) + assert not form.is_valid() + assert "authpass" in form.errors + assert "authalgo" in form.errors + + def test_auth_no_priv_valid_with_auth_fields(self): + form = self._make_form({"authlevel": "authNoPriv", "authpass": "secret", "authalgo": "SHA"}) + assert form.is_valid(), form.errors + + def test_auth_priv_requires_all_fields(self): + form = self._make_form({"authlevel": "authPriv", "authpass": "secret", "authalgo": "SHA"}) + assert not form.is_valid() + assert "cryptopass" in form.errors + assert "cryptoalgo" in form.errors + + def test_auth_priv_valid_with_all_fields(self): + form = self._make_form( + { + "authlevel": "authPriv", + "authpass": "secret", + "authalgo": "SHA", + "cryptopass": "encrypt", + "cryptoalgo": "AES", + } + ) + assert form.is_valid(), form.errors diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index 082a42e2c..30d8615c5 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -263,6 +263,33 @@ def test_all_cache_expired_logs_error(self): assert result == [] mock_logger.error.assert_called_once() + def test_load_job_results_sets_name_flags_from_job_data(self): + """_load_job_results mirrors use_sysname/strip_domain from job metadata.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = object.__new__(LibreNMSImportView) + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1], + "filters": {}, + "server_key": "default", + "vc_detection_enabled": False, + "use_sysname": False, + "strip_domain": True, + } + + with patch("core.models.Job") as mock_job_cls: + mock_job_cls.objects.get.return_value = mock_job + with patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key", return_value="cache_key"): + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = {"device_id": 1, "hostname": "router1"} + result = view._load_job_results(1) + + assert len(result) == 1 + assert view._use_sysname is False + assert view._strip_domain is True + class TestGetTable: """Tests for get_table().""" @@ -1143,8 +1170,70 @@ def test_get_settings_exception_is_caught(self): view.get(request) mock_render.assert_called_once() + def test_get_settings_exception_in_inline_load(self): + """LibreNMSSettings exception inside filter block is caught.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view, request = self._make_view(query_params={"apply_filters": "1", "librenms_location": "DC1"}) + + mock_api = MagicMock() + mock_api.server_key = "default" + + with patch.object(LibreNMSImportView, "librenms_api", new_callable=lambda: property(lambda self: mock_api)): + with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: + # First call (module-level read at top of get()) succeeds + # Second call (inline, inside the filter block) raises + first_call = [True] + + def first_then_raise(*a, **kw): + if first_call: + first_call.pop() + return None + raise Exception("DB error") + + mock_settings.objects.first.side_effect = first_then_raise + mock_settings.objects.get_or_create.return_value = (None, False) + + with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: + mock_pref.return_value = None + + mock_form_cls = MagicMock() + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = { + "enable_vc_detection": False, + "clear_cache": False, + "use_background_job": False, + } + mock_form_cls.return_value = mock_form + view.filterset_form = mock_form_cls + + with patch("netbox_librenms_plugin.views.imports.list.render") as mock_render: + mock_render.return_value = MagicMock() + + with patch("netbox_librenms_plugin.views.imports.list.DeviceImportTable"): + with patch( + "netbox_librenms_plugin.views.imports.list.get_active_cached_searches" + ) as mock_searches: + mock_searches.return_value = [] + + with patch.object(view, "get_server_info", return_value={}): + with patch( + "netbox_librenms_plugin.import_utils.get_cache_metadata_key" + ) as mock_meta: + mock_meta.return_value = "meta_key" + with patch( + "netbox_librenms_plugin.import_utils.get_device_count_for_filters" + ) as mock_count: + mock_count.return_value = 3 + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + # Should not raise despite the settings exception + view.get(request) + mock_render.assert_called_once() + def test_get_device_count_exception_defaults_zero(self): - """Device count exception falls back to 0 (lines 304-306).""" + """Device count exception falls back to 0.""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView view, request = self._make_view(query_params={"apply_filters": "1", "librenms_location": "DC1"}) @@ -1202,7 +1291,7 @@ def test_get_device_count_exception_defaults_zero(self): assert context["device_count"] == 0 def test_get_cache_check_exception_continues(self): - """Cache check exception is logged and processing continues (lines 293-294).""" + """Cache check exception is logged and processing continues.""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView view, request = self._make_view(query_params={"apply_filters": "1", "librenms_location": "DC1"}) @@ -1339,8 +1428,33 @@ def test_get_import_queryset_returns_empty_on_no_results(self): result = view._get_import_queryset() assert result == [] + def test_settings_exception_in_get_import_queryset(self): + """LibreNMSSettings exception in _get_import_queryset is caught.""" + view = self._make_view( + filter_data={ + "librenms_location": "DC1", + "enable_vc_detection": False, + "clear_cache": False, + } + ) + + with patch("netbox_librenms_plugin.views.imports.list.process_device_filters") as mock_process: + mock_process.return_value = ([], False) + + with patch("netbox_librenms_plugin.views.imports.list.get_user_pref") as mock_pref: + mock_pref.return_value = None + + with patch("netbox_librenms_plugin.views.imports.list.LibreNMSSettings") as mock_settings: + mock_settings.objects.first.side_effect = Exception("DB error") + + with patch("netbox_librenms_plugin.views.imports.list.cache") as mock_cache: + mock_cache.get.return_value = None + # Should not raise + result = view._get_import_queryset() + assert result == [] + def test_cache_metadata_found_sets_timestamps(self): - """When cache metadata is found, timestamps are set (lines 523-527).""" + """When cache metadata is found, timestamps are set.""" mock_device = {"device_id": 1, "_validation": {}} view = self._make_view( filter_data={ diff --git a/netbox_librenms_plugin/tests/test_coverage_mixins.py b/netbox_librenms_plugin/tests/test_coverage_mixins.py index 4ef4297c1..5c38e7594 100644 --- a/netbox_librenms_plugin/tests/test_coverage_mixins.py +++ b/netbox_librenms_plugin/tests/test_coverage_mixins.py @@ -169,8 +169,9 @@ def fake_scope(model_cls, objects): # SiteGroup ancestors should have been processed assert len(scope_calls) >= 1 - # Verify the SiteGroup model class was passed to _get_vlan_groups_for_scope + # Verify the SiteGroup model class and ancestor objects were passed to _get_vlan_groups_for_scope assert any(c[0] is MockSiteGroup for c in scope_calls) + assert any(c[0] is MockSiteGroup and c[1] == [site_group] for c in scope_calls) def test_device_with_location_triggers_location_scope_query(self): """When device.location is set, location-scoped VLAN groups are queried.""" @@ -566,6 +567,10 @@ def test_site_priority_path_executed(self): site_group.scope_type = site_ct site_group.scope_id = 7 + # Competing global group (less specific β€” scope_type=None) + global_group = MagicMock() + global_group.scope_type = None + device = MagicMock() device.rack = None device.location = None @@ -580,8 +585,9 @@ def test_site_priority_path_executed(self): patch("django.contrib.contenttypes.models.ContentType") as MockCT, ): MockCT.objects.get_for_model.return_value = site_ct - result = mixin._select_most_specific_group([site_group], device) + result = mixin._select_most_specific_group([global_group, site_group], device) + # site-scoped group wins over global group assert result is site_group def test_region_priority_path_executed(self): @@ -607,6 +613,10 @@ def test_region_priority_path_executed(self): region_group.scope_type = region_ct region_group.scope_id = 15 + # Competing global group (less specific β€” scope_type=None) + global_group = MagicMock() + global_group.scope_type = None + device = MagicMock() device.rack = None device.location = None @@ -629,21 +639,34 @@ def test_region_priority_path_executed(self): id(MockRegion): region_ct, } MockCT.objects.get_for_model.side_effect = lambda m: ct_map[id(m)] - result = mixin._select_most_specific_group([region_group], device) + result = mixin._select_most_specific_group([global_group, region_group], device) + # region-scoped group wins over global group assert result is region_group def test_global_scope_group_lowest_priority(self): - """Global scope group (scope_type=None) gets global_priority (line 523).""" + """Global scope group (scope_type=None) loses to any scoped group.""" mixin = self._make_mixin() global_group = MagicMock() global_group.scope_type = None # global + site = MagicMock() + site.pk = 5 + site.region = None + site.group = None + + site_ct = MagicMock() + site_ct.pk = 3 + + site_group = MagicMock() + site_group.scope_type = site_ct + site_group.scope_id = 5 + device = MagicMock() device.rack = None device.location = None - device.site = None + device.site = site with ( patch("dcim.models.Rack"), @@ -651,11 +674,13 @@ def test_global_scope_group_lowest_priority(self): patch("dcim.models.Site"), patch("dcim.models.SiteGroup"), patch("dcim.models.Region"), - patch("django.contrib.contenttypes.models.ContentType"), + patch("django.contrib.contenttypes.models.ContentType") as MockCT, ): - result = mixin._select_most_specific_group([global_group], device) + MockCT.objects.get_for_model.return_value = site_ct + result = mixin._select_most_specific_group([global_group, site_group], device) - assert result is global_group + # site-scoped group wins over global (global has lowest priority) + assert result is site_group def test_site_group_priority_path_executed(self): """Device with site.group executes site-group hierarchy path.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py b/netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py index 07ff4dfed..e3c91942b 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py @@ -442,6 +442,60 @@ def test_device_selection_does_not_exist_defaults_to_obj(self): call_kwargs = mock_intf_cls.objects.get_or_create.call_args[1] assert call_kwargs["device"] is mock_device + def test_device_port_id_prefers_existing_librenms_id_match(self): + from dcim.models import Device + + view = self._make_view() + mock_device = MagicMock() + mock_device.__class__ = Device + mock_device.id = 1 + mock_device.virtual_chassis = None + + matched_interface = MagicMock() + matched_interface.device_id = 1 + librenms_port = {"ifName": "Gi0/1", "port_id": 42} + + with ( + patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mock_intf_cls, + patch("netbox_librenms_plugin.views.sync.interfaces.find_by_librenms_id", return_value=matched_interface), + ): + view.get_netbox_interface_type = MagicMock(return_value="other") + view.sync_interface(mock_device, librenms_port, [], "ifName") + + mock_intf_cls.objects.get_or_create.assert_not_called() + view.update_interface_attributes.assert_called_once_with( + matched_interface, + librenms_port, + "other", + [], + "ifName", + ) + + def test_device_port_id_conflict_without_local_name_match_skips(self): + from dcim.models import Device + + view = self._make_view() + mock_device = MagicMock() + mock_device.__class__ = Device + mock_device.id = 1 + mock_device.virtual_chassis = None + + conflicting_interface = MagicMock() + conflicting_interface.device_id = 2 + librenms_port = {"ifName": "Gi0/1", "port_id": 77} + + with ( + patch("netbox_librenms_plugin.views.sync.interfaces.Interface") as mock_intf_cls, + patch( + "netbox_librenms_plugin.views.sync.interfaces.find_by_librenms_id", return_value=conflicting_interface + ), + ): + mock_intf_cls.objects.filter.return_value.first.return_value = None + view.get_netbox_interface_type = MagicMock(return_value="other") + view.sync_interface(mock_device, librenms_port, [], "ifName") + + view.update_interface_attributes.assert_not_called() + class TestSyncInterfacesViewSyncInterfaceVM: def test_vm_interface_created(self): @@ -468,6 +522,40 @@ def test_vm_interface_created(self): mock_vmintf_cls.objects.get_or_create.assert_called_once() view.update_interface_attributes.assert_called_once() + def test_vm_port_id_prefers_existing_librenms_id_match(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + from virtualization.models import VirtualMachine + + view = object.__new__(SyncInterfacesView) + view.request = _make_request() + view._post_server_key = "default" + view._lookup_maps = {} + view.interface_name_field = "ifName" + view.update_interface_attributes = MagicMock() + view._sync_interface_vlans = MagicMock() + + mock_vm = MagicMock() + mock_vm.__class__ = VirtualMachine + mock_vm.id = 5 + matched_interface = MagicMock() + matched_interface.virtual_machine_id = 5 + librenms_port = {"ifName": "eth0", "port_id": 55} + + with ( + patch("netbox_librenms_plugin.views.sync.interfaces.VMInterface") as mock_vmintf_cls, + patch("netbox_librenms_plugin.views.sync.interfaces.find_by_librenms_id", return_value=matched_interface), + ): + view.sync_interface(mock_vm, librenms_port, [], "ifName") + + mock_vmintf_cls.objects.get_or_create.assert_not_called() + view.update_interface_attributes.assert_called_once_with( + matched_interface, + librenms_port, + None, + [], + "ifName", + ) + def test_invalid_obj_raises_value_error(self): from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView import pytest @@ -754,12 +842,41 @@ def test_port_id_calls_set_librenms_device_id(self): with ( patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None), + patch("netbox_librenms_plugin.views.sync.interfaces.find_by_librenms_id", return_value=None), patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set, ): view.update_interface_attributes(interface, librenms_port, None, [], "ifName") mock_set.assert_called_once_with(interface, 42, "default") + def test_port_id_conflict_does_not_overwrite(self): + from dcim.models import Interface + + view = self._make_view() + interface = MagicMock() + interface.__class__ = Interface + interface.pk = 1 + conflicting_owner = MagicMock() + conflicting_owner.pk = 2 + librenms_port = { + "ifName": "Gi0/1", + "ifType": None, + "ifSpeed": None, + "ifAlias": None, + "ifMtu": None, + "port_id": 42, + "ifAdminStatus": "up", + } + + with ( + patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None), + patch("netbox_librenms_plugin.views.sync.interfaces.find_by_librenms_id", return_value=conflicting_owner), + patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set, + ): + view.update_interface_attributes(interface, librenms_port, None, [], "ifName") + + mock_set.assert_not_called() + def test_ifalias_not_set_when_same_as_name(self): """ifAlias should not overwrite when equal to interface name.""" from dcim.models import Interface diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_view.py b/netbox_librenms_plugin/tests/test_coverage_sync_view.py index c93647d0f..36c6b0e7c 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_view.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_view.py @@ -62,7 +62,8 @@ def test_get_vc_member_always_delegates_to_sync_device(self, mock_get_sync, mock view._librenms_api = MagicMock() view._librenms_api.server_key = "default" - view._librenms_api.get_librenms_id.return_value = 99 + # Member has no own librenms_id; get_librenms_id returns None after delegation + view._librenms_api.get_librenms_id.return_value = None view.get_context_data = MagicMock(return_value={}) mock_render.return_value = MagicMock() @@ -151,6 +152,11 @@ def test_vc_context_sync_device_has_id_and_ip(self): view._librenms_api = MagicMock() view._librenms_api.server_key = "default" view._librenms_api.librenms_url = "https://x.example.com" + # Explicitly set get_librenms_id return value so sync_device_has_librenms_id + # is determined by the patched get_librenms_device_id, not a bare MagicMock. + view._librenms_api.get_librenms_id.return_value = 42 + # Note: production code calls get_librenms_device_id() (module-level function), + # not self.librenms_api.get_librenms_id(). The patch below is the correct target. view.get_librenms_device_info = MagicMock( return_value={ @@ -205,6 +211,179 @@ def test_vc_context_sync_device_has_id_and_ip(self): assert ctx.get("sync_device_has_librenms_id") is True assert ctx.get("sync_device_has_primary_ip") is True + def test_vc_context_sync_device_has_no_id(self): + """VC device where get_librenms_device_id returns None β†’ sync_device_has_librenms_id is False.""" + view = _make_view() + view.librenms_id = 42 + view._librenms_lookup_device = MagicMock() + + obj = MagicMock() + obj.virtual_chassis = MagicMock() + obj._meta = MagicMock() + obj._meta.model_name = "device" + + sync_device = MagicMock() + sync_device.primary_ip = None # also no IP + sync_device._meta.model_name = "device" + sync_device.pk = 10 + + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view._librenms_api.librenms_url = "https://x.example.com" + # Explicitly set to None so sync_device_has_librenms_id computes as False + # (determined by the patched get_librenms_device_id returning None below). + view._librenms_api.get_librenms_id.return_value = None + + view.get_librenms_device_info = MagicMock( + return_value={ + "found_in_librenms": False, + "librenms_device_details": { + "librenms_device_serial": "", + "librenms_device_hardware": "-", + "librenms_device_os": "-", + "librenms_device_version": "-", + "librenms_device_features": "-", + "librenms_device_location": "-", + "librenms_device_hardware_match": None, + "vc_inventory_serials": [], + }, + "mismatched_device": False, + } + ) + view.get_interface_context = MagicMock(return_value=None) + view.get_cable_context = MagicMock(return_value=None) + view.get_ip_context = MagicMock(return_value=None) + view.get_vlan_context = MagicMock(return_value=None) + + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_sync_device") as mock_sync: + mock_sync.return_value = sync_device + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.get_librenms_device_id") as mock_id: + mock_id.return_value = None # No ID β†’ flag should be False + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field", + return_value="ifName", + ): + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._build_all_server_mappings", + return_value=None, + ): + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info", + return_value={}, + ): + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"): + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"): + with patch("dcim.models.Manufacturer") as MockMfr: + MockMfr.objects.all.return_value.order_by.return_value = [] + with patch.object(view, "get_context_data", wraps=view.get_context_data): + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data", + return_value={}, + ): + ctx = view.get_context_data(MagicMock(), obj) + + assert ctx.get("is_vc_member") is True + assert ctx.get("sync_device_has_librenms_id") is False + assert ctx.get("sync_device_has_primary_ip") is False + + +class TestContextAllTabsPresent: + """Regression coverage for sync-tab context keys.""" + + def test_get_context_data_contains_all_sync_tabs(self): + """Context always exposes all tab keys, including module_sync.""" + view = _make_view() + view.librenms_id = 42 + + obj = MagicMock() + obj.virtual_chassis = None + obj._meta = MagicMock() + obj._meta.model_name = "device" + obj.pk = 1 + obj.cf = {"librenms_id": {"default": 42}} + + interface_ctx = MagicMock() + cable_ctx = MagicMock() + ip_ctx = MagicMock() + vlan_ctx = MagicMock() + module_ctx = MagicMock() + + view.get_librenms_device_info = MagicMock( + return_value={ + "found_in_librenms": True, + "librenms_device_details": { + "librenms_device_serial": "SN001", + "librenms_device_hardware": "Cisco", + "librenms_device_os": "ios", + "librenms_device_version": "16.9", + "librenms_device_features": "-", + "librenms_device_location": "NYC", + "librenms_device_hardware_match": None, + "vc_inventory_serials": [], + }, + "mismatched_device": False, + } + ) + view.get_interface_context = MagicMock(return_value=interface_ctx) + view.get_cable_context = MagicMock(return_value=cable_ctx) + view.get_ip_context = MagicMock(return_value=ip_ctx) + view.get_vlan_context = MagicMock(return_value=vlan_ctx) + view.get_module_context = MagicMock(return_value=module_ctx) + + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.LibreNMSAPIMixin.get_context_data", + return_value={}, + ): + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.get_interface_name_field", return_value="ifName" + ): + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.BaseLibreNMSSyncView._get_platform_info", + return_value={}, + ): + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV1V2"): + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.AddToLIbreSNMPV3"): + with patch("dcim.models.Manufacturer") as MockMfr: + MockMfr.objects.all.return_value.order_by.return_value = [] + ctx = view.get_context_data(MagicMock(), obj) + + assert "interface_sync" in ctx + assert "cable_sync" in ctx + assert "ip_sync" in ctx + assert "vlan_sync" in ctx + assert "module_sync" in ctx + assert ctx["interface_sync"] is interface_ctx + assert ctx["module_sync"] is module_ctx + + +class TestModuleContextDefaults: + """Tests for module-context defaults and concrete overrides.""" + + def test_module_sync_is_none_when_not_overridden(self): + from netbox_librenms_plugin.views.object_sync.vms import VMLibreNMSSyncView + + base_view = _make_view() + assert base_view.get_module_context(MagicMock(), MagicMock()) is None + + vm_view = object.__new__(VMLibreNMSSyncView) + assert vm_view.get_module_context(MagicMock(), MagicMock()) is None + + def test_device_view_module_context_is_non_none(self): + from netbox_librenms_plugin.views.object_sync.devices import DeviceLibreNMSSyncView + + request = MagicMock() + obj = MagicMock() + view = object.__new__(DeviceLibreNMSSyncView) + + with patch( + "netbox_librenms_plugin.views.object_sync.devices.DeviceModuleTableView.get_context_data", + return_value={"modules": []}, + ) as mock_get_context: + result = view.get_module_context(request, obj) + + assert result == {"modules": []} + mock_get_context.assert_called_once() + class TestBuildAllServerMappings: """Tests for _build_all_server_mappings (lines 181, 193, 200, 207-208).""" @@ -663,9 +842,10 @@ def test_matching_platform_found(self): } } - with patch("dcim.models.Platform") as MockPlatform: - MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {}) - MockPlatform.objects.get.return_value = mock_platform + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.find_matching_platform", + return_value={"found": True, "platform": mock_platform, "match_type": "exact"}, + ): result = view._get_platform_info(librenms_info, obj) assert result["platform_exists"] is True @@ -683,9 +863,10 @@ def test_platform_does_not_exist(self): } } - with patch("dcim.models.Platform") as MockPlatform: - MockPlatform.DoesNotExist = type("DoesNotExist", (Exception,), {}) - MockPlatform.objects.get.side_effect = MockPlatform.DoesNotExist() + with patch( + "netbox_librenms_plugin.views.base.librenms_sync_view.find_matching_platform", + return_value={"found": False, "platform": None, "match_type": None}, + ): result = view._get_platform_info(librenms_info, obj) assert result["platform_exists"] is False diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_views.py b/netbox_librenms_plugin/tests/test_coverage_sync_views.py index 042bceb91..131b8abc0 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_views.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_views.py @@ -549,53 +549,61 @@ def test_vm_type_fetches_vm(self): result = view.get_object(5, "virtualmachine") assert result is mock_vm - def test_no_type_tries_device_first(self): - from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + def test_device_type_fetches_device(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView, Device view = _make_view(AddDeviceToLibreNMSView) mock_device = MagicMock() - with patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_dev_cls: - mock_dev_cls.objects.get.return_value = mock_device - mock_dev_cls.DoesNotExist = Exception - result = view.get_object(1) + with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_device) as mock_get: + result = view.get_object(1, "device") assert result is mock_device + mock_get.assert_called_once_with(Device, pk=1) - def test_device_not_found_falls_back_to_vm(self): - from dcim.models import Device +class TestAddDeviceToLibreNMSViewPost: + def test_permission_denied_returns_early(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = _make_view(AddDeviceToLibreNMSView) - mock_vm = MagicMock() - with patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_dev_cls: - mock_dev_cls.DoesNotExist = Device.DoesNotExist - mock_dev_cls.objects.get.side_effect = Device.DoesNotExist - with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_vm): - result = view.get_object(1) - assert result is mock_vm + view.request = _make_request({"object_type": "device"}) + mock_obj = MagicMock() + mock_error = MagicMock() + # Perm check now runs after the object is resolved so the mixin can + # build the right object-permission set (Device vs VirtualMachine). + with patch.object(view, "get_object", return_value=mock_obj): + with patch.object(view, "require_all_permissions", return_value=mock_error) as mock_perm: + result = view.post(view.request, object_id=1) + assert result is mock_error + mock_perm.assert_called_once_with("POST") + # Exactly one model in the dynamically-set required_object_permissions. + from dcim.models import Device + assert view.required_object_permissions == {"POST": [("change", Device)]} -class TestAddDeviceToLibreNMSViewPost: - def test_permission_denied_returns_early(self): + def test_invalid_object_type_returns_400_without_perm_check(self): + """Bad client input (missing/unknown object_type) must surface a 400 immediately, + without triggering the permission check β€” there's no model to check against yet.""" from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = _make_view(AddDeviceToLibreNMSView) - mock_error = MagicMock() - with patch.object(view, "require_write_permission", return_value=mock_error): - result = view.post(view.request, object_id=1) - assert result is mock_error + view.request = _make_request({"object_type": "bogus"}) + with patch.object(view, "get_object", return_value=None): + with patch.object(view, "require_all_permissions") as mock_perm: + response = view.post(view.request, object_id=1) + assert response.status_code == 400 + mock_perm.assert_not_called() def test_form_invalid_shows_error_and_redirects(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = _make_view(AddDeviceToLibreNMSView) - view.request = _make_request({"v1v2-snmp_version": "v2c", "snmp_version": "v2c"}) + view.request = _make_request({"v1v2-snmp_version": "v2c", "snmp_version": "v2c", "object_type": "device"}) mock_obj = MagicMock() mock_obj.get_absolute_url.return_value = "/dcim/devices/1/" mock_form = MagicMock() mock_form.is_valid.return_value = False mock_form.errors.items.return_value = [("hostname", ["This field is required."])] - with patch.object(view, "require_write_permission", return_value=None): + with patch.object(view, "require_all_permissions", return_value=None): with patch.object(view, "get_object", return_value=mock_obj): with patch.object(view, "get_form_class", return_value=MagicMock(return_value=mock_form)): with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg: @@ -608,13 +616,13 @@ def test_form_valid_injects_snmp_version_for_v2c(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = _make_view(AddDeviceToLibreNMSView) - view.request = _make_request({"v1v2-snmp_version": "v2c"}) + view.request = _make_request({"v1v2-snmp_version": "v2c", "object_type": "device"}) mock_obj = MagicMock() mock_obj.get_absolute_url.return_value = "/dcim/devices/1/" mock_form = MagicMock() mock_form.is_valid.return_value = True mock_form.cleaned_data = {} - with patch.object(view, "require_write_permission", return_value=None): + with patch.object(view, "require_all_permissions", return_value=None): with patch.object(view, "get_object", return_value=mock_obj): with patch.object(view, "get_form_class", return_value=MagicMock(return_value=mock_form)): with patch.object(view, "form_valid", return_value=MagicMock()): @@ -2575,3 +2583,92 @@ def test_no_vlans_created_shows_warning(self): with patch.object(view, "_redirect", return_value=MagicMock()): view._handle_create_vlans(req, mock_obj, "device", 1) mock_msg.warning.assert_called_once() + + +class TestSyncIPAddressesViewSetPrimaryIp: + """Phase 1: auto-match the LibreNMS management IP and set it as Primary IP.""" + + def _setup_view(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView + + view = _make_view(SyncIPAddressesView) + view._post_server_key = "default" + return view + + def test_same_host(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + assert V._same_host("10.0.0.1", "10.0.0.1") is True + assert V._same_host("10.0.0.1", "10.0.0.2") is False + assert V._same_host("not-an-ip", "10.0.0.1") is False + # IPv6 equality across differing textual forms + assert V._same_host("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001") is True + + def test_set_primary_ip_sets_ipv4_and_is_idempotent(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + ip_obj = MagicMock(family=4, pk=42) + obj = MagicMock() + obj.primary_ip4_id = None + assert V._set_primary_ip(obj, ip_obj) is True + assert obj.primary_ip4 is ip_obj + obj.save.assert_called_once() + + # Already pointing at this IP -> no change, no extra save + obj.save.reset_mock() + obj.primary_ip4_id = 42 + assert V._set_primary_ip(obj, ip_obj) is False + obj.save.assert_not_called() + + def test_set_primary_ip_uses_v6_field(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView as V + + ip_obj = MagicMock(family=6, pk=7) + obj = MagicMock() + obj.primary_ip6_id = None + assert V._set_primary_ip(obj, ip_obj) is True + assert obj.primary_ip6 is ip_obj + + def _run_process(self, view, cached, *, mgmt_ip, set_primary=True, interface=True): + selected = ["10.0.0.1"] + created_ip = MagicMock(family=4, pk=42) + obj = MagicMock() + obj.primary_ip4_id = None + with patch("netbox_librenms_plugin.views.sync.ip_addresses.resolve_set_primary_ip", return_value=set_primary): + with patch.object(view, "get_management_ip", return_value=mgmt_ip) as mock_mgmt: + with patch("netbox_librenms_plugin.views.sync.ip_addresses.transaction", _atomic_txn()): + with patch("netbox_librenms_plugin.views.sync.ip_addresses.IPAddress") as mock_ip_cls: + mock_ip_cls.objects.filter.return_value.first.return_value = None + mock_ip_cls.objects.create.return_value = created_ip + with patch("netbox_librenms_plugin.views.sync.ip_addresses.Interface") as mock_iface_cls: + mock_iface_cls.objects.get.return_value = MagicMock() + with patch.object(view, "get_vrf_selection", return_value=None): + results = view.process_ip_sync(view.request, selected, cached, obj, "device") + return results, obj, created_ip, mock_mgmt + + def test_primary_set_when_matched_and_interface_assigned(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, created_ip, _ = self._run_process(view, cached, mgmt_ip="10.0.0.1") + assert results["primary_set"] == ["10.0.0.1"] + assert obj.primary_ip4 is created_ip + + def test_primary_skipped_when_no_interface(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": None}] + results, obj, _, _ = self._run_process(view, cached, mgmt_ip="10.0.0.1") + assert results["primary_set"] == [] + obj.save.assert_not_called() + + def test_primary_skipped_when_ip_does_not_match_mgmt(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, _, _ = self._run_process(view, cached, mgmt_ip="10.9.9.9") + assert results["primary_set"] == [] + + def test_toggle_off_skips_mgmt_lookup_and_primary(self): + view = self._setup_view() + cached = [{"ip_address": "10.0.0.1", "ip_with_mask": "10.0.0.1/24", "interface_url": "/api/dcim/interfaces/5/"}] + results, obj, _, mock_mgmt = self._run_process(view, cached, mgmt_ip="10.0.0.1", set_primary=False) + assert results["primary_set"] == [] + mock_mgmt.assert_not_called() diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_views2.py b/netbox_librenms_plugin/tests/test_coverage_sync_views2.py index e484babb1..12bc5464d 100644 --- a/netbox_librenms_plugin/tests/test_coverage_sync_views2.py +++ b/netbox_librenms_plugin/tests/test_coverage_sync_views2.py @@ -117,7 +117,7 @@ def test_valid_cable_created(self): view = object.__new__(SyncCablesView) view.require_all_permissions = MagicMock(return_value=None) - view.request = _make_request(post_data={"select": ["port1"]}) + view.request = _make_request(post_data={"select": ["port1"], "device_selection_port1": "1"}) view.get_cache_key = MagicMock(return_value="key") view._post_server_key = "default" @@ -139,17 +139,18 @@ def test_valid_cable_created(self): patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"), patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls, - patch("netbox_librenms_plugin.views.sync.cables.transaction"), patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, + patch("netbox_librenms_plugin.views.sync.cables.transaction"), patch.object( type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default")) ), ): - mock_ct.objects.get_for_model.return_value = MagicMock() mock_cache.get.return_value = {"links": [link_data]} - local_iface.device_id = mock_device.id # match device_id to skip VC re-lookup + local_iface = MagicMock(pk=10) + local_iface.device_id = 1 # match selected_device_id ("1") to skip VC re-lookup mock_iface_cls.objects.get.side_effect = [local_iface, remote_iface] mock_cable_cls.objects.filter.return_value.exists.return_value = False + mock_ct.objects.get_for_model.return_value = MagicMock() view.post(view.request, pk=1) @@ -168,6 +169,7 @@ def test_duplicate_cable_shows_warning(self): view._post_server_key = "default" mock_device = MagicMock(pk=1) + mock_device.id = 1 # ensure id matches device_id on iface to skip VC branch link_data = { "local_port_id": "port1", "local_port": "Gi0/1", @@ -183,19 +185,19 @@ def test_duplicate_cable_shows_warning(self): patch("netbox_librenms_plugin.views.sync.cables.reverse", return_value="/sync/"), patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, patch("netbox_librenms_plugin.views.sync.cables.Interface") as mock_iface_cls, - patch("netbox_librenms_plugin.views.sync.cables.transaction"), patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, + patch("netbox_librenms_plugin.views.sync.cables.transaction"), patch.object( type(view), "librenms_api", new_callable=lambda: property(lambda s: MagicMock(server_key="default")) ), ): - mock_ct.objects.get_for_model.return_value = MagicMock() mock_cache.get.return_value = {"links": [link_data]} local_iface = MagicMock(pk=10) - local_iface.device_id = mock_device.id # match device_id to skip VC re-lookup + local_iface.device_id = 1 # match the device pk to skip VC branch remote_iface = MagicMock(pk=20) mock_iface_cls.objects.get.side_effect = [local_iface, remote_iface] mock_cable_cls.objects.filter.return_value.exists.return_value = True + mock_ct.objects.get_for_model.return_value = MagicMock() view.post(view.request, pk=1) @@ -431,8 +433,8 @@ def test_check_existing_cable(self): patch("netbox_librenms_plugin.views.sync.cables.Cable") as mock_cable_cls, patch("netbox_librenms_plugin.views.sync.cables.ContentType") as mock_ct, ): - mock_ct.objects.get_for_model.return_value = MagicMock() mock_cable_cls.objects.filter.return_value.exists.return_value = True + mock_ct.objects.get_for_model.return_value = MagicMock() result = view.check_existing_cable(local, remote) assert result is True @@ -467,10 +469,12 @@ def test_permission_denied_returns_early(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=_denied_response()) - view.request = _make_request(post_data={"snmp_version": "v2c"}) - - result = view.post(view.request, object_id=1) + view.require_all_permissions = MagicMock(return_value=_denied_response()) + view.request = _make_request(post_data={"snmp_version": "v2c", "object_type": "device"}) + # Permission check now runs after object resolution, so the get_object + # lookup must be stubbed out to reach it. + with patch.object(view, "get_object", return_value=MagicMock()): + result = view.post(view.request, object_id=1) assert result.status_code == 403 @@ -479,7 +483,7 @@ def test_invalid_form_shows_errors(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) mock_device = MagicMock() mock_device.get_absolute_url.return_value = "/device/1/" @@ -490,6 +494,7 @@ def test_invalid_form_shows_errors(self): patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_device_cls, patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msgs, patch("netbox_librenms_plugin.views.sync.devices.redirect"), + patch("netbox_librenms_plugin.forms._get_librenms_poller_group_choices", return_value=[]), ): mock_device_cls.objects.get.return_value = mock_device view.request = _make_request(post_data=post_data) @@ -498,8 +503,8 @@ def test_invalid_form_shows_errors(self): # Provide an invalid form (missing required hostname) view.post(view.request, object_id=1) - # Should show form errors - assert mock_msgs.error.call_count >= 0 # form validation may or may not find errors + # Should show form errors (hostname and community are required) + assert mock_msgs.error.call_count >= 1 class TestAddDeviceToLibreNMSViewFormValid: @@ -507,7 +512,7 @@ def test_valid_v2c_form_calls_api(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) mock_device = MagicMock() mock_device.get_absolute_url.return_value = "/device/1/" @@ -553,7 +558,7 @@ def test_valid_form_with_transport_and_pam(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) mock_device = MagicMock() mock_device.get_absolute_url.return_value = "/device/1/" @@ -597,7 +602,7 @@ def test_invalid_poller_group_ignored(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) mock_device = MagicMock() mock_device.get_absolute_url.return_value = "/device/1/" @@ -641,7 +646,7 @@ def test_v3_form_submits_v3_data(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) view.request = _make_request() mock_device = MagicMock() @@ -689,7 +694,7 @@ def test_unknown_snmp_version_shows_error(self): from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - view.require_write_permission = MagicMock(return_value=None) + view.require_all_permissions = MagicMock(return_value=None) mock_device = MagicMock() mock_device.get_absolute_url.return_value = "/device/1/" @@ -755,23 +760,29 @@ def test_get_object_virtualmachine(self): result = view.get_object(5, object_type="virtualmachine") assert result is mock_vm - def test_get_object_device_not_found_falls_back_to_vm(self): + def test_get_object_returns_none_for_missing_object_type(self): + """Missing object_type now returns None (caller turns it into HTTP 400).""" from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView view = object.__new__(AddDeviceToLibreNMSView) - mock_vm = MagicMock() + assert view.get_object(5) is None + assert view.get_object(5, object_type="bogus") is None - class _DNE(Exception): - pass + def test_get_object_raises_http404_when_device_not_found(self): + from django.http import Http404 - with ( - patch("netbox_librenms_plugin.views.sync.devices.Device") as mock_dev_cls, - patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=mock_vm), + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + + view = object.__new__(AddDeviceToLibreNMSView) + + import pytest + + with patch( + "netbox_librenms_plugin.views.sync.devices.get_object_or_404", + side_effect=Http404, ): - mock_dev_cls.DoesNotExist = _DNE - mock_dev_cls.objects.get.side_effect = _DNE() - result = view.get_object(5) - assert result is mock_vm + with pytest.raises(Http404): + view.get_object(5, object_type="device") def test_form_valid_with_poller_group(self): """poller_group valid int is passed to API.""" @@ -1494,8 +1505,11 @@ def test_vlan_created_in_group(self): view.request = _make_request(post_data={"action": "create_vlans", "select": ["200"], "vlan_group_200": "3"}) view.post(view.request, object_type="device", object_id=1) - call_kwargs = mock_vlan_cls.objects.get_or_create.call_args[1] - assert call_kwargs.get("group") is mock_vlan_group or mock_vlan_cls.objects.get_or_create.called + mock_vlan_cls.objects.get_or_create.assert_called_once_with( + vid=200, + group=mock_vlan_group, + defaults={"name": "Production", "status": "active"}, + ) def test_invalid_vlan_group_id_falls_back_to_global(self): from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView diff --git a/netbox_librenms_plugin/tests/test_coverage_utils.py b/netbox_librenms_plugin/tests/test_coverage_utils.py index b190a747f..5f0fa2e00 100644 --- a/netbox_librenms_plugin/tests/test_coverage_utils.py +++ b/netbox_librenms_plugin/tests/test_coverage_utils.py @@ -451,22 +451,33 @@ def test_multiple_objects_returned_uses_first(self): class TestFindMatchingPlatformMultipleReturned: """Tests for find_matching_platform MultipleObjectsReturned (lines 358-360).""" - def test_multiple_objects_returned_uses_first(self): + def test_multiple_objects_returned_returns_ambiguous(self): from netbox_librenms_plugin.utils import find_matching_platform - mock_platform = MagicMock() Platform_DoesNotExist = type("DoesNotExist", (Exception,), {}) Platform_MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) - - with patch("dcim.models.Platform") as MockPlatform: - MockPlatform.DoesNotExist = Platform_DoesNotExist - MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned - MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple") - MockPlatform.objects.filter.return_value.first.return_value = mock_platform - - result = find_matching_platform("ios") - assert result["found"] is True - assert result["platform"] is mock_platform + PlatformMapping_DoesNotExist = type("DoesNotExist", (Exception,), {}) + + with patch("netbox_librenms_plugin.models.PlatformMapping") as MockPlatformMapping: + MockPlatformMapping.DoesNotExist = PlatformMapping_DoesNotExist + MockPlatformMapping.objects.get.side_effect = PlatformMapping_DoesNotExist("no mapping") + + with patch("dcim.models.Platform") as MockPlatform: + MockPlatform.DoesNotExist = Platform_DoesNotExist + MockPlatform.MultipleObjectsReturned = Platform_MultipleObjectsReturned + MockPlatform.objects.get.side_effect = Platform_MultipleObjectsReturned("multiple") + + result = find_matching_platform("ios") + assert result["found"] is False + assert result["platform"] is None + assert result["match_type"] == "ambiguous" + assert result["ambiguity_source"] == "platform" + # Per the fix for the "PlatformMapping never consulted" CodeRabbit + # finding: when Platform.MultipleObjectsReturned fires, the function + # now defers the ambiguity decision and consults PlatformMapping + # first so an explicit override can disambiguate. Only when the + # mapping also misses do we surface ambiguous(platform). + MockPlatformMapping.objects.get.assert_called_once_with(librenms_os__iexact="ios") class TestGetMissingVlanWarning: @@ -549,3 +560,128 @@ def test_none_id_returns_none_without_query(self): result = find_by_librenms_id(model, None, server_key="default") assert result is None model.objects.filter.assert_not_called() + + +class TestNetboxResolvesModuleTokenPerLeaf: + """Version-gating helper for {module} token resolution behaviour (NetBox #20467).""" + + def test_returns_true_for_4_5_6(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "_get_netbox_version_tuple", return_value=(4, 5, 6)): + assert utils.netbox_resolves_module_token_per_leaf() is True + + def test_returns_true_for_4_6_0(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "_get_netbox_version_tuple", return_value=(4, 6, 0)): + assert utils.netbox_resolves_module_token_per_leaf() is True + + def test_returns_false_for_4_5_5(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "_get_netbox_version_tuple", return_value=(4, 5, 5)): + assert utils.netbox_resolves_module_token_per_leaf() is False + + def test_returns_false_for_4_4_10(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "_get_netbox_version_tuple", return_value=(4, 4, 10)): + assert utils.netbox_resolves_module_token_per_leaf() is False + + def test_returns_true_when_version_undetectable(self): + """Permissive default: avoid false positives on unknown versions.""" + from netbox_librenms_plugin import utils + + with patch.object(utils, "_get_netbox_version_tuple", return_value=None): + assert utils.netbox_resolves_module_token_per_leaf() is True + + +class TestHasNestedNameConflictVersionGating: + """has_nested_name_conflict() must short-circuit on NetBox >= 4.5.6 (issue #20467).""" + + def _build_args(self, with_module_token=True): + """Build (module_type, module_bay, sibling_counts) that would trigger + the legacy conflict (nested bay, sibling exists, {module} in template).""" + template = MagicMock() + template.name = "{module}" if with_module_token else "Gi0/1" + module_type = MagicMock() + module_type.model = "X2-10GB-LR" + module_type.interfacetemplates.all.return_value = [template] + + module_bay = MagicMock() + module_bay.module_id = 820 + module_bay.device = MagicMock() + + sibling_counts = {820: 8} + return module_type, module_bay, sibling_counts + + def test_skipped_on_supported_netbox(self): + from netbox_librenms_plugin import utils + + module_type, module_bay, sibling_counts = self._build_args() + with patch.object(utils, "netbox_resolves_module_token_per_leaf", return_value=True): + result = utils.has_nested_name_conflict(module_type, module_bay, sibling_counts) + assert result == "" + + def test_warns_on_old_netbox(self): + from netbox_librenms_plugin import utils + + module_type, module_bay, sibling_counts = self._build_args() + with patch.object(utils, "netbox_resolves_module_token_per_leaf", return_value=False): + result = utils.has_nested_name_conflict(module_type, module_bay, sibling_counts) + assert result != "" + assert "X2-10GB-LR" in result + assert "4.5.6" in result + assert "20467" in result + + def test_old_netbox_no_module_token_no_conflict(self): + from netbox_librenms_plugin import utils + + module_type, module_bay, sibling_counts = self._build_args(with_module_token=False) + with patch.object(utils, "netbox_resolves_module_token_per_leaf", return_value=False): + result = utils.has_nested_name_conflict(module_type, module_bay, sibling_counts) + assert result == "" + + def test_old_netbox_top_level_bay_no_conflict(self): + from netbox_librenms_plugin import utils + + module_type, module_bay, sibling_counts = self._build_args() + module_bay.module_id = None + with patch.object(utils, "netbox_resolves_module_token_per_leaf", return_value=False): + result = utils.has_nested_name_conflict(module_type, module_bay, sibling_counts) + assert result == "" + + def test_old_netbox_single_sibling_no_conflict(self): + from netbox_librenms_plugin import utils + + module_type, module_bay, sibling_counts = self._build_args() + sibling_counts = {820: 1} + with patch.object(utils, "netbox_resolves_module_token_per_leaf", return_value=False): + result = utils.has_nested_name_conflict(module_type, module_bay, sibling_counts) + assert result == "" + + +class TestGetNetboxVersionTuple: + """Parse netbox.settings.RELEASE.version into a comparable tuple.""" + + def test_parses_standard_version(self): + from netbox_librenms_plugin import utils + + fake_release = MagicMock(version="4.5.8") + with patch("netbox.settings.RELEASE", fake_release): + assert utils._get_netbox_version_tuple() == (4, 5, 8) + + def test_strips_build_suffix(self): + from netbox_librenms_plugin import utils + + fake_release = MagicMock(version="4.5.8-Docker-4.0.2") + with patch("netbox.settings.RELEASE", fake_release): + assert utils._get_netbox_version_tuple() == (4, 5, 8) + + def test_returns_none_on_unparseable(self): + from netbox_librenms_plugin import utils + + fake_release = MagicMock(version="not-a-version") + with patch("netbox.settings.RELEASE", fake_release): + assert utils._get_netbox_version_tuple() is None diff --git a/netbox_librenms_plugin/tests/test_import_utils.py b/netbox_librenms_plugin/tests/test_import_utils.py index db0f70f07..f7d8620c0 100644 --- a/netbox_librenms_plugin/tests/test_import_utils.py +++ b/netbox_librenms_plugin/tests/test_import_utils.py @@ -2847,8 +2847,8 @@ def test_update_skips_dash_serial(self, mock_cache_key, mock_cache): # Serial should NOT be updated to '-' assert existing_device.serial == "EXISTING" - def test_missing_action_returns_400(self): - """Missing action or existing_device_id should return 400.""" + def test_missing_action_returns_hx_noswap_response(self): + """Missing action or existing_device_id returns 200 with HX-Reswap=none.""" view = self._create_view() request = MagicMock() request.user.has_perm.return_value = True @@ -2856,10 +2856,11 @@ def test_missing_action_returns_400(self): view.request = request response = view.post(request, device_id=10) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" - def test_unknown_action_returns_400(self): - """Unknown action should return 400.""" + def test_unknown_action_returns_hx_noswap_response(self): + """Unknown action returns 200 with HX-Reswap=none.""" from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView view = self._create_view() @@ -2884,7 +2885,8 @@ def test_unknown_action_returns_400(self): view.request = request response = view.post(request, device_id=10) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" @patch("netbox_librenms_plugin.views.imports.actions.cache") @patch("netbox_librenms_plugin.views.imports.actions.get_import_device_cache_key") @@ -2929,8 +2931,8 @@ def test_sync_name_action_updates_name(self, mock_cache_key, mock_cache): @patch("netbox_librenms_plugin.views.imports.actions.cache") @patch("netbox_librenms_plugin.views.imports.actions.get_import_device_cache_key") - def test_device_type_mismatch_blocked_without_force(self, mock_cache_key, mock_cache): - """Action should be blocked when device_type_mismatch is True and force is not set.""" + def test_device_type_mismatch_blocked_without_force_returns_hx_noswap(self, mock_cache_key, mock_cache): + """Blocked mismatch returns 200 with HX-Reswap=none when force is not set.""" from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView view = self._create_view() @@ -2956,7 +2958,8 @@ def test_device_type_mismatch_blocked_without_force(self, mock_cache_key, mock_c view.request = request response = view.post(request, device_id=10) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.headers.get("HX-Reswap") == "none" existing_device.save.assert_not_called() @patch("netbox_librenms_plugin.views.imports.actions.cache") @@ -5892,3 +5895,70 @@ def _capture_create(**kwargs): create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) assert mock_device_cls.objects.create.call_count == 1 + + +class TestResolveSetPrimaryIp: + """Phase 1: resolve_set_primary_ip cascade (POST/GET toggle -> user pref -> False).""" + + def _make_request(self, post=None, get=None): + from unittest.mock import MagicMock + + request = MagicMock() + request.POST = post or {} + request.GET = get or {} + request.user = MagicMock() + return request + + def test_defaults_false_when_nothing_set(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request() + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is False + + def test_post_toggle_on(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set-primary-ip-toggle": "on"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_post_toggle_off_overrides_pref(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set-primary-ip-toggle": "off"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=True): + assert resolve_set_primary_ip(request) is False + + def test_underscore_key_variant(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(post={"set_primary_ip": "1"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_get_used_when_not_in_post(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request(get={"set-primary-ip-toggle": "true"}) + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=None): + assert resolve_set_primary_ip(request) is True + + def test_user_pref_bool_used_when_no_toggle(self): + from unittest.mock import patch + + from netbox_librenms_plugin.utils import resolve_set_primary_ip + + request = self._make_request() + with patch("netbox_librenms_plugin.utils.get_user_pref", return_value=True): + assert resolve_set_primary_ip(request) is True diff --git a/netbox_librenms_plugin/tests/test_init.py b/netbox_librenms_plugin/tests/test_init.py index fe2274fc9..f2c22797e 100644 --- a/netbox_librenms_plugin/tests/test_init.py +++ b/netbox_librenms_plugin/tests/test_init.py @@ -63,6 +63,8 @@ def test_creates_custom_field_when_missing( "is_cloneable": False, }, ) + MockCustomField.objects.using.assert_called_with("default") + MockContentType.objects.db_manager.assert_called_with("default") # Should have added content types for all 4 models assert mock_cf.object_types.add.call_count == 4 @@ -104,6 +106,8 @@ def test_existing_field_not_recreated( # All pks already present, no types should be added mock_cf.object_types.add.assert_not_called() + MockCustomField.objects.using.assert_called_with("default") + MockContentType.objects.db_manager.assert_called_with("default") @patch("dcim.models.Interface", new_callable=MagicMock) @patch("dcim.models.Device", new_callable=MagicMock) diff --git a/netbox_librenms_plugin/tests/test_integration_virtual_chassis.py b/netbox_librenms_plugin/tests/test_integration_virtual_chassis.py index a6dd1e98e..cb89f6f84 100644 --- a/netbox_librenms_plugin/tests/test_integration_virtual_chassis.py +++ b/netbox_librenms_plugin/tests/test_integration_virtual_chassis.py @@ -128,6 +128,9 @@ def test_members_sorted_by_position(self, mock_server): assert result is not None positions = [m["position"] for m in result["members"]] assert positions == [1, 2, 3] + # Verify serial/position pairs are correctly associated after sorting + member_pairs = [(m["serial"], m["position"]) for m in result["members"]] + assert member_pairs == [("SN-1", 1), ("SN-2", 2), ("SN-3", 3)] def test_position_zero_falls_back_to_idx_plus_one(self, mock_server): """position=0 in entPhysicalParentRelPos β†’ fallback to idx+1 (never 0).""" @@ -154,6 +157,9 @@ def test_position_zero_falls_back_to_idx_plus_one(self, mock_server): positions = [m["position"] for m in result["members"]] # Both had position=0, so they fall back to idx+1: positions [1, 2] assert all(p >= 1 for p in positions) + # Verify each fallback position is uniquely paired with its serial + member_pairs = [(m["serial"], m["position"]) for m in result["members"]] + assert member_pairs == [("SN-X", 1), ("SN-Y", 2)] def test_member_fields_extracted_correctly(self, mock_server): """serial, model, name, description all extracted from chassis entries.""" @@ -852,3 +858,56 @@ def test_all_port_fields_preserved(self, mock_server): assert port["ifAlias"] == "server-link" assert port["ifSpeed"] == 1_000_000_000 assert port["ifMtu"] == 9000 + + +class TestCrossServerCacheIsolation: + """Cache keys are scoped per server_key β€” data from one server must not bleed into another.""" + + def test_different_server_keys_use_isolated_cache_entries(self, mock_server): + """Data cached via server-a must not be returned when querying the same device via server-b.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + device_id = 300 + + api_a = _make_api(mock_server.url, server_key="server-a") + api_b = _make_api(mock_server.url, server_key="server-b") + + # Register 2-member VC for server-a path + mock_server.device_info_response(device_id=device_id, hostname="sw-server-a") + root_items = [_stack_root(index=1)] + member_items = [_chassis(100, "SN-A1", position=1), _chassis(200, "SN-A2", position=2)] + mock_server.vc_inventory_callable(device_id, root_items, {1: member_items}) + + cache_store = {} + + def mock_cache_set(key, val, timeout=None): + cache_store[key] = val + + def mock_cache_get(key): + return cache_store.get(key) + + with patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache: + mock_cache.get.side_effect = mock_cache_get + mock_cache.set.side_effect = mock_cache_set + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="{master}-m{position}", + ): + # Warm cache via server-a + result_a = get_virtual_chassis_data(api_a, device_id) + + # Query same device via server-b β€” cache must miss (different key) + result_b = get_virtual_chassis_data(api_b, device_id) + + # server-a result is a 2-member VC + assert result_a is not None + assert result_a.get("member_count") == 2 + + # server-b was registered with the same mock routes, so it also fetches a 2-member VC. + # The key assertion is that TWO separate cache entries exist β€” one per server_key. + server_a_keys = [k for k in cache_store if "server-a" in k] + server_b_keys = [k for k in cache_store if "server-b" in k] + assert len(server_a_keys) >= 1, "server-a cache entry expected" + assert len(server_b_keys) >= 1, "server-b cache entry expected" + assert server_a_keys[0] != server_b_keys[0], "cache keys must differ between servers" + assert result_a is not result_b, "must not return the same cached object for different servers" diff --git a/netbox_librenms_plugin/tests/test_librenms_api.py b/netbox_librenms_plugin/tests/test_librenms_api.py index 24bc9e921..176b289f0 100644 --- a/netbox_librenms_plugin/tests/test_librenms_api.py +++ b/netbox_librenms_plugin/tests/test_librenms_api.py @@ -501,8 +501,10 @@ def test_get_librenms_id_empty_string_falls_through_to_discovery(self, mock_libr device.cf = {"librenms_id": ""} device.primary_ip = None - with patch.object(api, "get_device_id_by_hostname", return_value=None): - result = api.get_librenms_id(device) + with patch("netbox_librenms_plugin.librenms_api.cache") as mock_cache: + mock_cache.get.return_value = None + with patch.object(api, "get_device_id_by_hostname", return_value=None): + result = api.get_librenms_id(device) assert result is None @patch("netbox_librenms_plugin.librenms_api.cache") @@ -521,6 +523,51 @@ def test_get_librenms_id_from_cache(self, mock_cache, mock_librenms_config): result = api.get_librenms_id(device) assert result == 99 + @patch("netbox_librenms_plugin.librenms_api.cache") + def test_get_librenms_id_handles_objects_without_device_identity_attrs(self, mock_cache, mock_librenms_config): + """Objects like interfaces should return None cleanly when they have no stored or cached ID.""" + from types import SimpleNamespace + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + interface = SimpleNamespace( + cf={}, + _meta=SimpleNamespace(model_name="interface"), + pk=123, + ) + + mock_cache.get.return_value = None + + result = api.get_librenms_id(interface) + + assert result is None + + @patch("netbox_librenms_plugin.librenms_api.cache") + def test_get_stored_librenms_id_skips_hostname_lookup(self, mock_cache, mock_librenms_config): + """Stored-only helper must not trigger discovery lookups for interface-like objects.""" + from types import SimpleNamespace + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + interface = SimpleNamespace( + cf={}, + name="GigabitEthernet1/0/1", + _meta=SimpleNamespace(model_name="interface"), + pk=123, + ) + + mock_cache.get.return_value = None + + with patch.object(api, "get_device_id_by_hostname") as mock_hostname_lookup: + result = api.get_stored_librenms_id(interface) + + assert result is None + mock_hostname_lookup.assert_not_called() + @patch("netbox_librenms_plugin.librenms_api.cache") @patch("netbox_librenms_plugin.librenms_api.requests.get") def test_get_librenms_id_by_ip_lookup(self, mock_get, mock_cache, mock_librenms_config): @@ -1345,3 +1392,380 @@ def test_mixed_bad_entries_no_exception(self, mock_librenms_config): assert result["tagged_vlans"] == [30] assert result["untagged_vlan"] is None + + +# ==================================================================================== +# Response-shape regression tests for get_device_transceivers / get_device_info / +# get_device_vlans / get_port_vlan_details. These guard the malformed-payload +# branches that were tightened up alongside the inventory/transceiver work. +# ==================================================================================== + + +class TestGetDeviceInfoResponseShape: + """Cover response-shape branches in get_device_info().""" + + pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_device_entry_returns_failure(self, mock_get, mock_librenms_config): + """A non-dict entry in the devices list must not propagate as truthy data.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "devices": ["not-a-dict"]} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, data = api.get_device_info(device_id=1) + + assert success is False + assert data is None + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_missing_devices_key_returns_failure(self, mock_get, mock_librenms_config): + """KeyError on missing 'devices' must be caught and return (False, None).""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok"} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, data = api.get_device_info(device_id=1) + + assert success is False + assert data is None + + +class TestGetDeviceTransceiversResponseShape: + """Cover response-shape branches in get_device_transceivers().""" + + pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_success_returns_transceiver_list(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "transceivers": [ + {"port_id": 1, "type": "QSFP28", "serial": "SN1"}, + {"port_id": 2, "type": "SFP+", "serial": "SN2"}, + ], + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, data = api.get_device_transceivers(device_id=123) + + assert success is True + assert len(data) == 2 + assert data[0]["serial"] == "SN1" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_invalid_json_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.side_effect = ValueError("bad json") + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert "Invalid JSON" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_response_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = ["unexpected", "list"] + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert "Unexpected" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_missing_transceivers_key_uses_server_message(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "message": "no transceivers MIB"} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert msg == "no transceivers MIB" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_status_not_ok_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "error", + "transceivers": [], + "message": "device offline", + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert msg == "device offline" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_transceivers_not_list_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "transceivers": {"port_id": 1}, + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert "Unexpected" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_malformed_transceiver_entry_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "transceivers": [{"port_id": 1}, None], + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert "Malformed" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_request_exception_returns_failure(self, mock_get, mock_librenms_config): + mock_get.side_effect = requests.exceptions.ConnectionError("boom") + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_transceivers(device_id=123) + + assert success is False + assert "boom" in msg + + +class TestGetDeviceVlansResponseShape: + """Cover response-shape branches in get_device_vlans().""" + + pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_success_filters_by_device_id(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "vlans": [ + {"vlan_id": 1, "device_id": 7, "vlan_vlan": 10, "vlan_name": "DATA"}, + {"vlan_id": 2, "device_id": 8, "vlan_vlan": 20, "vlan_name": "VOICE"}, + ], + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, vlans = api.get_device_vlans(device_id=7) + + assert success is True + assert len(vlans) == 1 + assert vlans[0]["vlan_vlan"] == 10 + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_vlans_not_list_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "vlans": "oops", + "message": "bad payload", + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert msg == "bad payload" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_item_in_vlans_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "vlans": [{"vlan_id": 1, "device_id": 7}, "not-a-dict"], + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert "invalid item shape" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_status_not_ok_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "error", "message": "nope"} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert msg == "nope" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_response_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = ["unexpected"] + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert msg == "Unexpected response format" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_http_404_returns_dedicated_message(self, mock_get, mock_librenms_config): + response = MagicMock(status_code=404) + mock_get.return_value.raise_for_status.side_effect = requests.exceptions.HTTPError(response=response) + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert msg == "VLANs resource not found" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_value_error_returns_connection_message(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.side_effect = ValueError("bad json") + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_device_vlans(device_id=7) + + assert success is False + assert "Error connecting to LibreNMS" in msg + + +class TestGetPortVlanDetailsResponseShape: + """Cover response-shape branches in get_port_vlan_details().""" + + pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_success_returns_port_dict(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "status": "ok", + "port": [{"port_id": 11, "ifName": "Te1/1/1", "vlans": [{"vlan": 10, "untagged": 1}]}], + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, port = api.get_port_vlan_details(port_id=11) + + assert success is True + assert port["port_id"] == 11 + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_response_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = ["unexpected"] + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert msg == "Unexpected response format" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_status_not_ok_uses_server_message(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "error", "message": "no port"} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert msg == "no port" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_missing_port_list_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "port": {"port_id": 11}} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert "missing 'port' list" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_empty_port_list_returns_not_found(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "port": []} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert msg == "Port not found" + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_non_dict_port_entry_returns_failure(self, mock_get, mock_librenms_config): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "port": ["bad-entry"]} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert "invalid 'port' entry" in msg + + @patch("netbox_librenms_plugin.librenms_api.requests.get") + def test_request_exception_returns_failure(self, mock_get, mock_librenms_config): + mock_get.side_effect = requests.exceptions.ConnectionError("net down") + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, msg = api.get_port_vlan_details(port_id=11) + + assert success is False + assert "net down" in msg diff --git a/netbox_librenms_plugin/tests/test_librenms_id.py b/netbox_librenms_plugin/tests/test_librenms_id.py index 075e71610..5fa555985 100644 --- a/netbox_librenms_plugin/tests/test_librenms_id.py +++ b/netbox_librenms_plugin/tests/test_librenms_id.py @@ -1,5 +1,4 @@ -""" -Tests for multi-server librenms_id helpers. +"""Tests for multi-server librenms_id helpers. Covers get_librenms_device_id, set_librenms_device_id, find_by_librenms_id, and migrate_legacy_librenms_id. @@ -28,8 +27,7 @@ def test_returns_int_for_legacy_bare_integer(self): assert result == 42 def test_legacy_bare_int_returned_for_any_server_key(self): - """ - Legacy bare integers are returned as a universal fallback for any server_key. + """Legacy bare integers are returned as a universal fallback for any server_key. Devices imported before multi-server support store a bare integer in librenms_id. These must remain discoverable regardless of which server is @@ -106,8 +104,7 @@ class TestFindByLibreNMSId: """Tests for find_by_librenms_id().""" def test_queries_server_key_and_legacy_integer(self): - """ - find_by_librenms_id() issues a Q that covers both the JSON server-key branch + """find_by_librenms_id() issues a Q that covers both the JSON server-key branch and the legacy bare-int branch in a single filter() call. We inspect the Q object's children directly because the two branches must @@ -131,12 +128,12 @@ def test_queries_server_key_and_legacy_integer(self): q_arg = call_args[0][0] assert isinstance(q_arg, Q) assert q_arg.connector == "OR" - # The combined Q should contain four children: JSON key (int), JSON key (str), bare-int, bare-string - q_str = str(q_arg) - assert "librenms_id__default" in q_str - assert "custom_field_data__librenms_id__default" in q_str - assert "custom_field_data__librenms_id" in q_str - assert "42" in q_str + assert set(q_arg.children) == { + ("custom_field_data__librenms_id__default", 42), + ("custom_field_data__librenms_id__default", "42"), + ("custom_field_data__librenms_id", 42), + ("custom_field_data__librenms_id", "42"), + } def test_returns_first_matching_object(self): from netbox_librenms_plugin.utils import find_by_librenms_id @@ -168,12 +165,16 @@ def test_returns_none_when_not_found(self): call_args = mock_model.objects.filter.call_args q_arg = call_args[0][0] assert isinstance(q_arg, Q) - q_str = str(q_arg) - assert "custom_field_data__librenms_id__production" in q_str - assert "custom_field_data__librenms_id" in q_str + assert set(q_arg.children) == { + ("custom_field_data__librenms_id__production", 999), + ("custom_field_data__librenms_id__production", "999"), + ("custom_field_data__librenms_id", 999), + ("custom_field_data__librenms_id", "999"), + } def test_default_server_key_is_default(self): - """find_by_librenms_id() uses "default" as the server key when no key is passed. + """ + find_by_librenms_id() uses "default" as the server key when no key is passed. We inspect the Q predicate's children to confirm the key embedded in the JSON path is exactly "default", not some other fallback value. @@ -194,8 +195,14 @@ def test_default_server_key_is_default(self): q_arg = call_args[0][0] assert isinstance(q_arg, Q) assert q_arg.connector == "OR" - q_str = str(q_arg) - assert "custom_field_data__librenms_id__default" in q_str + # The JSON-path branch must use "default" as the server key; exact tuple check prevents + # duplicate or missing branches from going undetected. + assert set(q_arg.children) == { + ("custom_field_data__librenms_id__default", 42), + ("custom_field_data__librenms_id__default", "42"), + ("custom_field_data__librenms_id", 42), + ("custom_field_data__librenms_id", "42"), + } class TestMigrateLegacyLibreNMSId: diff --git a/netbox_librenms_plugin/tests/test_module_replace.py b/netbox_librenms_plugin/tests/test_module_replace.py new file mode 100644 index 000000000..802f88cad --- /dev/null +++ b/netbox_librenms_plugin/tests/test_module_replace.py @@ -0,0 +1,488 @@ +"""Tests for ModuleMismatchPreviewView, ReplaceModuleView, and MoveModuleView.""" + +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_device(pk=24, name="test-device"): + d = MagicMock() + d.pk = pk + d.name = name + d.device_type = MagicMock() + d.device_type.manufacturer = None + return d + + +def _make_module(pk=42, serial="OLD_SERIAL", bay_name="Slot 1", bay_id=10, type_id=5, type_model="XCM-7s"): + module = MagicMock() + module.pk = pk + module.serial = serial + module.module_bay = MagicMock() + module.module_bay.pk = bay_id + module.module_bay.name = bay_name + module.module_bay_id = bay_id + module.module_type = MagicMock() + module.module_type.pk = type_id + module.module_type.model = type_model + module.module_type_id = type_id + module.device = _make_device() + module.get_absolute_url.return_value = f"/dcim/modules/{pk}/" + return module + + +def _make_request(method="GET", data=None): + req = MagicMock() + req.method = method + if method == "GET": + req.GET = data or {} + else: + req.POST = data or {} + return req + + +# --------------------------------------------------------------------------- +# ModuleMismatchPreviewView +# --------------------------------------------------------------------------- + + +class TestModuleMismatchPreviewView: + def _view(self): + from netbox_librenms_plugin.views.sync.modules import ModuleMismatchPreviewView + + v = object.__new__(ModuleMismatchPreviewView) + v._librenms_api = MagicMock() + v._librenms_api.server_key = "default" + return v + + def test_missing_params_returns_400(self): + """GET without module_id or ent_index returns 400.""" + view = self._view() + device = _make_device() + request = _make_request(data={}) + view.request = request + + with patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device): + resp = view.get(request, pk=24) + + assert resp.status_code == 400 + + def test_invalid_ent_index_returns_400(self): + """GET with non-integer ent_index returns 400.""" + view = self._view() + device = _make_device() + request = _make_request(data={"module_id": "42", "ent_index": "notanint"}) + view.request = request + + with patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device): + resp = view.get(request, pk=24) + + assert resp.status_code == 400 + + def test_no_cache_returns_400(self): + """GET with valid params but no cached inventory returns 400.""" + view = self._view() + device = _make_device() + installed = _make_module() + request = _make_request(data={"module_id": "42", "ent_index": "100"}) + view.request = request + + with ( + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, installed], + ), + patch.object(view, "get_cache_key", return_value="cache-key"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + ): + mock_cache.get.return_value = None + resp = view.get(request, pk=24) + + assert resp.status_code == 400 + + def test_item_not_in_cache_returns_400(self): + """GET returns 400 when ent_index not found in cached data.""" + view = self._view() + device = _make_device() + installed = _make_module() + request = _make_request(data={"module_id": "42", "ent_index": "999"}) + view.request = request + cached = [{"entPhysicalIndex": 100, "entPhysicalModelName": "XCM-7s", "entPhysicalSerialNum": "S1"}] + + with ( + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, installed], + ), + patch.object(view, "get_cache_key", return_value="cache-key"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + ): + mock_cache.get.return_value = {"inventory": cached, "librenms_id": "test"} + resp = view.get(request, pk=24) + + assert resp.status_code == 400 + + def test_renders_template_on_success(self): + """GET with valid data returns 200 with rendered template.""" + from django.http import HttpResponse + + view = self._view() + device = _make_device() + installed = _make_module(type_id=5, type_model="XCM-7s") + request = _make_request(data={"module_id": "42", "ent_index": "100"}) + view.request = request + cached = [{"entPhysicalIndex": 100, "entPhysicalModelName": "XCM-7s", "entPhysicalSerialNum": "NS123"}] + + matched_type = MagicMock() + matched_type.pk = 5 + + with ( + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, installed], + ), + patch.object(view, "get_cache_key", return_value="cache-key"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch( + "netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", + return_value={"XCM-7s": matched_type}, + ), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s"), + patch("dcim.models.Module") as mock_module_cls, + patch("netbox_librenms_plugin.views.sync.modules.render", return_value=HttpResponse("OK")) as mock_render, + ): + mock_cache.get.return_value = {"inventory": cached, "librenms_id": "test"} + mock_module_cls.objects.filter.return_value.exclude.return_value.select_related.return_value.count.return_value = 0 + mock_module_cls.objects.filter.return_value.exclude.return_value.select_related.return_value.first.return_value = None + resp = view.get(request, pk=24) + + assert resp.status_code == 200 + mock_render.assert_called_once() + ctx = mock_render.call_args[0][2] + assert ctx["device_pk"] == 24 + assert ctx["librenms_serial"] == "NS123" + + def test_serial_conflict_passed_to_template(self): + """When serial exists elsewhere, serial_conflict is set in template context.""" + from django.http import HttpResponse + + view = self._view() + device = _make_device() + installed = _make_module(serial="OLD", type_id=5) + request = _make_request(data={"module_id": "42", "ent_index": "100"}) + view.request = request + cached = [{"entPhysicalIndex": 100, "entPhysicalModelName": "XCM-7s", "entPhysicalSerialNum": "NEW_SERIAL"}] + + matched_type = MagicMock() + matched_type.pk = 5 + conflict_module = MagicMock() + + with ( + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, installed], + ), + patch.object(view, "get_cache_key", return_value="cache-key"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch( + "netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", + return_value={"XCM-7s": matched_type}, + ), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s"), + patch("dcim.models.Module") as mock_module_cls, + patch("netbox_librenms_plugin.views.sync.modules.render", return_value=HttpResponse("OK")) as mock_render, + ): + mock_cache.get.return_value = {"inventory": cached, "librenms_id": "test"} + mock_module_cls.objects.filter.return_value.exclude.return_value.select_related.return_value.count.return_value = 1 + mock_module_cls.objects.filter.return_value.exclude.return_value.select_related.return_value.first.return_value = conflict_module + view.get(request, pk=24) + + ctx = mock_render.call_args[0][2] + assert ctx["serial_conflict"] is conflict_module + + +# --------------------------------------------------------------------------- +# ReplaceModuleView +# --------------------------------------------------------------------------- + + +class TestReplaceModuleView: + def _view(self): + from netbox_librenms_plugin.views.sync.modules import ReplaceModuleView + + v = object.__new__(ReplaceModuleView) + # Bypass permission mixin + v.required_object_permissions = {} + v._librenms_api = MagicMock() + v._librenms_api.server_key = "default" + return v + + def test_missing_params_redirects_with_error(self): + """POST without module_id or ent_index adds error and redirects.""" + view = self._view() + device = _make_device() + request = _make_request("POST", data={}) + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect") as mock_redirect, + ): + view.post(request, pk=24) + + mock_msg.error.assert_called_once() + mock_redirect.assert_called_once() + + def test_no_cache_redirects_with_error(self): + """POST with valid params but no cache adds error and redirects.""" + view = self._view() + device = _make_device() + installed = _make_module() + request = _make_request("POST", data={"module_id": "42", "ent_index": "100"}) + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, installed]), + patch.object(view, "require_all_permissions", return_value=None), + patch.object(view, "get_cache_key", return_value="ck"), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect") as mock_redirect, + ): + mock_cache.get.return_value = None + view.post(request, pk=24) + + mock_msg.error.assert_called_once() + mock_redirect.assert_called_once() + + def test_replace_deletes_old_and_creates_new(self): + """POST with valid data deletes old module and creates new one.""" + view = self._view() + device = _make_device() + sync_device = _make_device(pk=999, name="vc-sync-device") + installed = _make_module(serial="OLD", type_id=5) + request = _make_request("POST", data={"module_id": "42", "ent_index": "100", "server_key": "prod"}) + cached = [{"entPhysicalIndex": 100, "entPhysicalModelName": "XCM-7s", "entPhysicalSerialNum": "NEW"}] + matched_type = MagicMock() + matched_type.model = "XCM-7s" + + new_module = MagicMock() + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, installed]), + patch.object(view, "require_all_permissions", return_value=None), + patch.object(view, "get_cache_key", return_value="ck") as mock_get_cache_key, + patch( + "netbox_librenms_plugin.views.sync.modules._get_sync_device_for_inventory", + return_value=sync_device, + ) as mock_get_sync_device, + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch( + "netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", + return_value={"XCM-7s": matched_type}, + ), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s"), + patch("netbox_librenms_plugin.views.sync.modules.transaction") as mock_tx, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + patch("dcim.models.Module") as mock_module_cls, + ): + mock_cache.get.return_value = {"inventory": cached, "librenms_id": "test"} + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_module_cls.return_value = new_module + # New code does TWO select_for_update() calls inside the atomic + # block β€” one for the installed-module re-fetch, one for the + # serial-conflict re-derivation (returns empty list here). + installed_chain = MagicMock() + installed_chain.filter.return_value.select_related.return_value.first.return_value = installed + conflict_chain = MagicMock() + conflict_chain.filter.return_value.exclude.return_value.select_related.return_value.__iter__ = lambda s: ( + iter([]) + ) + mock_module_cls.objects.select_for_update.side_effect = [installed_chain, conflict_chain] + + view.post(request, pk=24) + + installed.delete.assert_called_once() + new_module.full_clean.assert_called_once() + new_module.save.assert_called_once() + mock_msg.success.assert_called_once() + # server_key from POST must be forwarded to the cache key lookup + mock_get_sync_device.assert_called_with(device, "prod") + mock_get_cache_key.assert_called_with(sync_device, "inventory", server_key="prod") + + def test_replace_removes_serial_conflict_from_db(self): + """POST re-derives the conflicting module from serial, not from conflict_module_id.""" + view = self._view() + device = _make_device() + installed = _make_module(serial="OLD", type_id=5) + # No conflict_module_id in POST β€” conflict must be derived from serial + request = _make_request("POST", data={"module_id": "42", "ent_index": "100", "server_key": "prod"}) + cached = [ + {"entPhysicalIndex": 100, "entPhysicalModelName": "XCM-7s", "entPhysicalSerialNum": "CONFLICT_SERIAL"} + ] + matched_type = MagicMock() + matched_type.model = "XCM-7s" + + conflict = _make_module(pk=55, serial="CONFLICT_SERIAL", bay_name="Slot 3") + new_module = MagicMock() + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, installed]), + patch.object(view, "require_all_permissions", return_value=None), + patch.object(view, "get_cache_key", return_value="ck"), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch( + "netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", + return_value={"XCM-7s": matched_type}, + ), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s"), + patch("netbox_librenms_plugin.views.sync.modules.transaction") as mock_tx, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + patch("dcim.models.Module") as mock_module_cls, + ): + mock_cache.get.return_value = {"inventory": cached, "librenms_id": "test"} + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_module_cls.return_value = new_module + # New code: both the installed re-fetch and the serial-conflict + # re-derivation use select_for_update() to lock both rows inside + # the atomic block. Configure the chained mocks accordingly: + # 1. installed = Module.objects.select_for_update() + # .filter(pk=, device=).select_related().first() + # 2. conflicts = list(Module.objects.select_for_update() + # .filter(serial=).exclude(pk=).select_related(...)) + installed_chain = MagicMock() + installed_chain.filter.return_value.select_related.return_value.first.return_value = installed + conflict_chain = MagicMock() + conflict_chain.filter.return_value.exclude.return_value.select_related.return_value.__iter__ = lambda s: ( + iter([conflict]) + ) + mock_module_cls.objects.select_for_update.side_effect = [installed_chain, conflict_chain] + + view.post(request, pk=24) + + # Conflict module must be deleted, then the installed module, then new one saved + conflict.delete.assert_called_once() + installed.delete.assert_called_once() + new_module.save.assert_called_once() + mock_msg.info.assert_called_once() + mock_msg.success.assert_called_once() + + def test_requires_all_permissions(self): + """POST returns early when require_all_permissions returns a response.""" + from django.http import HttpResponse + + view = self._view() + device = _make_device() + request = _make_request("POST", data={"module_id": "42", "ent_index": "100"}) + + deny = HttpResponse(status=403) + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch.object(view, "require_all_permissions", return_value=deny), + ): + resp = view.post(request, pk=24) + + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# MoveModuleView +# --------------------------------------------------------------------------- + + +class TestMoveModuleView: + def _view(self): + from netbox_librenms_plugin.views.sync.modules import MoveModuleView + + v = object.__new__(MoveModuleView) + v.required_object_permissions = {} + return v + + def test_missing_params_redirects_with_error(self): + """POST without conflict_module_id or target_bay_id redirects with error.""" + view = self._view() + device = _make_device() + request = _make_request("POST", data={}) + + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect") as mock_redirect, + ): + view.post(request, pk=24) + + mock_msg.error.assert_called_once() + mock_redirect.assert_called_once() + + def test_move_updates_module_bay(self): + """POST moves conflict_module to target_bay.""" + view = self._view() + device = _make_device(pk=24) + conflict_module = _make_module(pk=99, serial="SN1", bay_name="Slot 3", bay_id=30) + target_bay = MagicMock() + target_bay.name = "Slot 1" + target_bay.pk = 10 + request = _make_request("POST", data={"conflict_module_id": "99", "target_bay_id": "10"}) + + with ( + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, target_bay], + ), + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.transaction") as mock_tx, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_msg, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + patch("dcim.models.Module") as mock_module_cls, + patch("dcim.models.ModuleBay") as mock_bay_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + # select_for_update on ModuleBay returns locked target_bay + mock_bay_cls.objects.select_for_update.return_value.get.return_value = target_bay + # select_for_update chain returns conflict_module + sfu_qs = MagicMock() + sfu_qs.filter.return_value.select_related.return_value.first.return_value = conflict_module + mock_module_cls.objects.select_for_update.return_value = sfu_qs + # No occupant in target bay + mock_module_cls.objects.select_for_update.return_value.filter.return_value.first.return_value = None + + view.post(request, pk=24) + + assert conflict_module.module_bay is target_bay + assert conflict_module.device is device + conflict_module.full_clean.assert_called_once() + conflict_module.save.assert_called_once() + mock_msg.success.assert_called_once() + + def test_requires_all_permissions(self): + """POST returns early when require_all_permissions returns a response.""" + from django.http import HttpResponse + + view = self._view() + device = _make_device() + request = _make_request("POST", data={"conflict_module_id": "99", "target_bay_id": "10"}) + + deny = HttpResponse(status=403) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch.object(view, "require_all_permissions", return_value=deny), + ): + resp = view.post(request, pk=24) + + assert resp.status_code == 403 diff --git a/netbox_librenms_plugin/tests/test_modules_view.py b/netbox_librenms_plugin/tests/test_modules_view.py new file mode 100644 index 000000000..1284eabd6 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_modules_view.py @@ -0,0 +1,4500 @@ +""" +Tests for BaseModuleTableView sync logic (modules_view.py). + +Focuses on the bay-scope tracking in _build_context and the serial +comparison logic in _build_row. +""" + +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_view(): + """Instantiate BaseModuleTableView bypassing __init__.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._librenms_api = MagicMock(server_key="test-server") + view.get_cache_key = MagicMock(return_value="test_cache_key") + return view + + +def _captured_table_view(view): + """Replace get_table with a version that captures the raw table_data list.""" + rows_store = {} + + def fake_get_table(table_data, obj): + rows_store["rows"] = table_data + m = MagicMock() + m.configure = MagicMock() + return m + + view.get_table = fake_get_table + return rows_store + + +def _run_build_context(view, inventory_data, device_bays, module_scoped_bays, module_types, bay_mappings=None): + """Call _build_context with all DB-accessing calls mocked out. + + `bay_mappings` is an optional (exact_list, regex_list) tuple of ModuleBayMapping-like + objects. When None, mappings are empty and matching exercises only direct-name + and positional fallbacks. + """ + rows_store = _captured_table_view(view) + view._get_module_bays = MagicMock(return_value=(device_bays, module_scoped_bays)) + view._get_module_types = MagicMock(return_value=module_types) + view._get_generic_module_types = MagicMock(return_value={}) + view._get_module_type_ambiguities = MagicMock(return_value={}) + view._get_carrier_install_rules = MagicMock(return_value=[]) + + if bay_mappings is None: + bay_mappings = ([], []) + + with ( + patch("netbox_librenms_plugin.views.base.modules_view.cache") as mock_cache, + patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=bay_mappings), + patch("netbox_librenms_plugin.utils.get_enabled_ignore_rules", return_value=[]), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda v, *a, **kw: v), + patch("netbox_librenms_plugin.utils.preload_normalization_rules", return_value={}), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + # _detect_serial_conflicts makes a real DB query; mock it out for unit tests + patch.object(view.__class__, "_detect_serial_conflicts", return_value=None), + ): + mock_cache.ttl = MagicMock(return_value=None) + + # Inline import: patch ModuleBayMapping inside models module + view._build_context(MagicMock(), MagicMock(), inventory_data) + + return rows_store.get("rows", []) + + +def _load_contrib_bay_mappings(): + """Load contrib bay mappings as fake ModuleBayMapping objects (no DB).""" + import re as _re + from pathlib import Path + + import yaml + + contrib_path = Path(__file__).resolve().parents[2] / "contrib" / "module_bay_mappings.yaml" + with open(contrib_path) as f: + data = yaml.safe_load(f) + + class _FakeMap: + def __init__(self, **kw): + self.librenms_name = kw["librenms_name"] + self.librenms_class = kw.get("librenms_class") or "" + self.netbox_bay_name = kw["netbox_bay_name"] + self.is_regex = kw.get("is_regex", False) + self._compiled_pattern = None + if self.is_regex: + try: + self._compiled_pattern = _re.compile(self.librenms_name) + except _re.error: + pass + + mappings = [_FakeMap(**m) for m in data] + exact = [m for m in mappings if not m.is_regex] + regex = [m for m in mappings if m.is_regex] + return exact, regex + + +class TestMergeTransceiverDataPortIdentity: + """Transceiver merge should preserve stable port identity metadata.""" + + def test_synthetic_item_includes_port_identity_metadata(self): + view = _make_view() + view.librenms_id = 100 + view._librenms_api.get_device_transceivers.return_value = ( + True, + [ + { + "entity_physical_index": 200, + "model": "SFP-10G-SR", + "serial": "TX-200", + "type": "SFP", + "port_id": 42, + } + ], + ) + view._librenms_api.get_ports.return_value = (True, {"ports": [{"port_id": 42, "ifName": "Te1/0/1"}]}) + + inventory, error = view._merge_transceiver_data([]) + + assert error is None + assert len(inventory) == 1 + item = inventory[0] + assert item["_from_transceiver_api"] is True + assert item["_librenms_port_id"] == 42 + assert item["_librenms_ifname"] == "Te1/0/1" + assert item["entPhysicalName"] == "Te1/0/1" + + def test_existing_inventory_item_gets_port_identity_metadata(self): + view = _make_view() + view.librenms_id = 101 + view._librenms_api.get_device_transceivers.return_value = ( + True, + [ + { + "entity_physical_index": 300, + "model": "", + "serial": "", + "type": "SFP", + "port_id": 99, + } + ], + ) + view._librenms_api.get_ports.return_value = (True, {"ports": [{"port_id": 99, "ifName": "Eth2/1"}]}) + inventory_seed = [ + { + "entPhysicalIndex": 300, + "entPhysicalName": "Transceiver slot", + "entPhysicalModelName": "builtin", + "entPhysicalSerialNum": "-", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 0, + } + ] + + inventory, error = view._merge_transceiver_data(inventory_seed) + + assert error is None + assert len(inventory) == 1 + item = inventory[0] + assert item["_librenms_port_id"] == 99 + assert item["_librenms_ifname"] == "Eth2/1" + + def test_enrich_inventory_port_identity_backfills_port_rows_from_ports_api(self): + view = _make_view() + view.librenms_id = 102 + view._librenms_api.get_ports.return_value = ( + True, + { + "ports": [ + { + "port_id": 56284, + "ifName": "TenGigabitEthernet1/1/1", + "ifDescr": "Te1/1/1", + } + ] + }, + ) + + inventory = [ + { + "entPhysicalClass": "port", + "entPhysicalName": "Te1/1/1", + "entPhysicalDescr": "TenGigabitEthernet1/1/1", + } + ] + + view._enrich_inventory_port_identity(inventory) + + assert inventory[0]["_librenms_port_id"] == 56284 + assert inventory[0]["_librenms_ifname"] == "TenGigabitEthernet1/1/1" + assert inventory[0]["_librenms_ifdescr"] == "Te1/1/1" + + def test_enrich_inventory_port_identity_skips_ambiguous_labels(self): + view = _make_view() + view.librenms_id = 103 + view._librenms_api.get_ports.return_value = ( + True, + { + "ports": [ + {"port_id": 10, "ifName": "Te1/1/1", "ifDescr": "Uplink A"}, + {"port_id": 11, "ifName": "Te1/1/1", "ifDescr": "Uplink B"}, + ] + }, + ) + + inventory = [{"entPhysicalClass": "port", "entPhysicalName": "Te1/1/1"}] + + view._enrich_inventory_port_identity(inventory) + + assert "_librenms_port_id" not in inventory[0] + + def test_build_port_name_map_uses_provided_ports_payload_without_api_fetch(self): + view = _make_view() + view.librenms_id = 104 + view._librenms_api.get_ports.side_effect = AssertionError("get_ports should not be called") + + port_map = view._build_port_name_map( + [{"port_id": 42}], + ports_data={ + "ports": [ + { + "port_id": 42, + "ifName": "Te1/0/1", + "ifDescr": "TenGigabitEthernet1/0/1", + } + ] + }, + ) + + assert port_map[42]["ifName"] == "Te1/0/1" + assert port_map[42]["ifDescr"] == "TenGigabitEthernet1/0/1" + + def test_enrich_inventory_port_identity_uses_provided_ports_payload_without_api_fetch(self): + view = _make_view() + view.librenms_id = 105 + view._librenms_api.get_ports.side_effect = AssertionError("get_ports should not be called") + + inventory = [{"entPhysicalClass": "port", "entPhysicalName": "Te1/1/1"}] + view._enrich_inventory_port_identity( + inventory, + ports_data={ + "ports": [ + { + "port_id": 56284, + "ifName": "TenGigabitEthernet1/1/1", + "ifDescr": "Te1/1/1", + } + ] + }, + ) + + assert inventory[0]["_librenms_port_id"] == 56284 + assert inventory[0]["_librenms_ifname"] == "TenGigabitEthernet1/1/1" + assert inventory[0]["_librenms_ifdescr"] == "Te1/1/1" + + def test_post_fetches_ports_once_and_reuses_payload(self): + view = _make_view() + view.model = MagicMock() + obj = MagicMock() + request = MagicMock() + + view.get_object = MagicMock(return_value=obj) + view._get_sync_device = MagicMock(return_value=obj) + view.has_write_permission = MagicMock(return_value=True) + view._build_context = MagicMock( + return_value={ + "table": None, + "object": obj, + "cache_expiry": None, + "server_key": view._librenms_api.server_key, + } + ) + + view._librenms_api.get_librenms_id.return_value = 777 + view._librenms_api.get_device_inventory.return_value = (True, []) + view._librenms_api.get_device_transceivers.return_value = (True, []) + ports_payload = {"ports": []} + view._librenms_api.get_ports.return_value = (True, ports_payload) + + with ( + patch("netbox_librenms_plugin.views.base.modules_view.cache"), + patch("netbox_librenms_plugin.views.base.modules_view.messages"), + patch("netbox_librenms_plugin.views.base.modules_view.render", return_value=MagicMock()), + patch.object(view, "_merge_transceiver_data", wraps=view._merge_transceiver_data) as mock_merge, + patch.object( + view, "_enrich_inventory_port_identity", wraps=view._enrich_inventory_port_identity + ) as mock_enrich, + ): + view.post(request, pk=1) + + assert view._librenms_api.get_ports.call_count == 1 + assert mock_merge.call_args.kwargs.get("ports_data") == ports_payload + assert mock_enrich.call_args.kwargs.get("ports_data") == ports_payload + + def test_post_warns_when_ports_fetch_fails(self): + view = _make_view() + view.model = MagicMock() + obj = MagicMock() + request = MagicMock() + + view.get_object = MagicMock(return_value=obj) + view._get_sync_device = MagicMock(return_value=obj) + view.has_write_permission = MagicMock(return_value=True) + view._build_context = MagicMock( + return_value={ + "table": None, + "object": obj, + "cache_expiry": None, + "server_key": view._librenms_api.server_key, + } + ) + + view._librenms_api.get_librenms_id.return_value = 777 + view._librenms_api.get_device_inventory.return_value = (True, []) + view._librenms_api.get_device_transceivers.return_value = (True, []) + view._librenms_api.get_ports.return_value = (False, "ports api unavailable") + + with ( + patch("netbox_librenms_plugin.views.base.modules_view.cache"), + patch("netbox_librenms_plugin.views.base.modules_view.messages") as mock_messages, + patch("netbox_librenms_plugin.views.base.modules_view.render", return_value=MagicMock()), + ): + view.post(request, pk=1) + + mock_messages.warning.assert_called_once_with( + request, + "Inventory refreshed, but port metadata fetch failed; interface matching may be incomplete." + " See server logs for details.", + ) + mock_messages.success.assert_not_called() + + +# --------------------------------------------------------------------------- +# Inventory data factories +# --------------------------------------------------------------------------- + + +def _linecard_inventory(): + """ + Minimal inventory modelling the prod-lab03-sw4 scenario: + + Linecard(slot 3) [WS-X4908, module, top-level] + X2 Port 2 [container, no model] + Converter 3/2 [CVR-X2-SFP, other] β€” INSTALLED in NetBox + SFP slot [container, no model] + GE3/11 [GLC-TE, port, serial=MTC213403BB] + X2 Port 4 [container, no model] + Converter 3/4 [CVR-X2-SFP, other] β€” NOT installed in NetBox + SFP slot 4 [container, no model] + GE3/15 [GLC-T, port, serial=MTC19330SQC] + """ + return [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Slot 3", + "entPhysicalModelName": "WS-X4908", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + "entPhysicalSerialNum": "S_LINECARD", + "entPhysicalParentRelPos": 3, + }, + # --- X2 Port 2 branch (installed CVR) --- + { + "entPhysicalIndex": 10, + "entPhysicalName": "X2 Port 2", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 2, + }, + { + "entPhysicalIndex": 11, + "entPhysicalName": "Converter 3/2", + "entPhysicalModelName": "CVR-X2-SFP", + "entPhysicalClass": "other", + "entPhysicalContainedIn": 10, + "entPhysicalSerialNum": "FDO_CVR2", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 12, + "entPhysicalName": "SFP slot", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 11, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 13, + "entPhysicalName": "GigabitEthernet3/11", + "entPhysicalModelName": "GLC-TE", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 12, + "entPhysicalSerialNum": "MTC213403BB", + "entPhysicalParentRelPos": 1, + }, + # --- X2 Port 4 branch (NOT installed CVR) --- + { + "entPhysicalIndex": 20, + "entPhysicalName": "X2 Port 4", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 4, + }, + { + "entPhysicalIndex": 21, + "entPhysicalName": "Converter 3/4", + "entPhysicalModelName": "CVR-X2-SFP", + "entPhysicalClass": "other", + "entPhysicalContainedIn": 20, + "entPhysicalSerialNum": "FDO_CVR4", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 22, + "entPhysicalName": "SFP slot 4", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 21, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 23, + "entPhysicalName": "GigabitEthernet3/15", + "entPhysicalModelName": "GLC-T", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 22, + "entPhysicalSerialNum": "MTC19330SQC", + "entPhysicalParentRelPos": 1, + }, + ] + + +def _bay_setup(): + """Build mock device_bays and module_scoped_bays matching _linecard_inventory.""" + # --- module instances (NetBox Module objects) --- + linecard_module = MagicMock() + linecard_module.pk = 100 + linecard_module.serial = "S_LINECARD" + linecard_module.module_type_id = 10 # matches mt_linecard.pk + + cvr2_module = MagicMock() + cvr2_module.pk = 200 + cvr2_module.serial = "FDO_CVR2" + cvr2_module.module_type_id = 20 # matches mt_cvr.pk + + glc_te_installed = MagicMock() + glc_te_installed.serial = "MTC213403BB" + glc_te_installed.get_absolute_url.return_value = "/modules/99/" + glc_te_installed.module_type_id = 30 # matches mt_glc_te.pk + + # --- device-level bays --- + slot3_bay = MagicMock() + slot3_bay.name = "Slot 3" + slot3_bay.installed_module = linecard_module + device_bays = {"Slot 3": slot3_bay} + + # --- module-scoped bays created by the linecard --- + x2p2_bay = MagicMock() + x2p2_bay.name = "X2 Port 2" + x2p2_bay.installed_module = cvr2_module # INSTALLED + + x2p4_bay = MagicMock() + x2p4_bay.name = "X2 Port 4" + x2p4_bay.installed_module = None # NOT installed + + # --- module-scoped bays created by the installed CVR at X2 Port 2 --- + sfp1_bay = MagicMock() + sfp1_bay.name = "SFP 1" + sfp1_bay.installed_module = glc_te_installed + + sfp2_bay = MagicMock() + sfp2_bay.name = "SFP 2" + sfp2_bay.installed_module = None + + module_scoped_bays = { + 100: {"X2 Port 2": x2p2_bay, "X2 Port 4": x2p4_bay}, + 200: {"SFP 1": sfp1_bay, "SFP 2": sfp2_bay}, + } + + return device_bays, module_scoped_bays + + +def _module_types(): + """Minimal module-type dict for the test scenario.""" + mt_linecard = MagicMock() + mt_linecard.pk = 10 + mt_linecard.model = "WS-X4908" + mt_cvr = MagicMock() + mt_cvr.pk = 20 + mt_cvr.model = "CVR-X2-SFP" + mt_glc_te = MagicMock() + mt_glc_te.pk = 30 + mt_glc_te.model = "GLC-TE" + mt_glc_t = MagicMock() + mt_glc_t.pk = 40 + mt_glc_t.model = "GLC-T" + return { + "WS-X4908": mt_linecard, + "CVR-X2-SFP": mt_cvr, + "GLC-TE": mt_glc_te, + "GLC-T": mt_glc_t, + } + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestBayDepthScopeWithUninstalledParent: + """ + Regression tests for the stale bays_by_depth bug. + + Scenario: two converters at depth-1 share the same parent linecard. + Converter 3/2 IS installed (it has SFP child bays). + Converter 3/4 is NOT installed (no SFP child bays exist yet in NetBox). + + Bug: bays_by_depth[2] is set when processing Converter 3/2, and NOT + cleared when processing Converter 3/4. GigabitEthernet3/15 (depth-2 + child of Converter 3/4) then inherits the stale SFP scope and gets + "Serial Mismatch" instead of "No Bay". + + Fix: when a matched bay has no installed module, set bays_by_depth[depth+1] + to {} to prevent leakage to subsequent siblings at the same depth. + """ + + def _build_rows(self): + view = _make_view() + device_bays, module_scoped_bays = _bay_setup() + module_types = _module_types() + return _run_build_context(view, _linecard_inventory(), device_bays, module_scoped_bays, module_types) + + def _row(self, rows, name): + for r in rows: + if r.get("name") == name: + return r + return None + + def test_glc_t_under_installed_converter_is_installed(self): + """GLC-TE under the installed Converter 3/2 must show 'Installed'.""" + rows = self._build_rows() + row = self._row(rows, "GigabitEthernet3/11") + assert row is not None, "GigabitEthernet3/11 row not found" + assert row["status"] == "Installed", ( + f"Expected 'Installed' but got {row['status']!r} β€” GLC-TE under an installed CVR should be Installed" + ) + + def test_glc_t_under_uninstalled_converter_is_no_bay_not_serial_mismatch(self): + """ + GLC-T under the uninstalled Converter 3/4 must show 'No Bay'. + + Before the fix, bays_by_depth[2] retains the SFP scope from + Converter 3/2 and GigabitEthernet3/15 incorrectly gets 'Serial Mismatch'. + """ + rows = self._build_rows() + row = self._row(rows, "GigabitEthernet3/15") + assert row is not None, "GigabitEthernet3/15 row not found" + assert row["status"] != "Serial Mismatch", ( + "GigabitEthernet3/15 shows 'Serial Mismatch' β€” stale bays_by_depth scope " + "leaking from Converter 3/2 into Converter 3/4's child items (regression)" + ) + assert row["status"] == "No Bay", ( + f"Expected 'No Bay' but got {row['status']!r}; " + "the parent converter is not installed so child SFPs cannot be matched" + ) + + def test_uninstalled_converter_itself_shows_matched(self): + """Converter 3/4 is matched to X2 Port 4 but not yet installed β†’ 'Matched'.""" + rows = self._build_rows() + row = self._row(rows, "Converter 3/4") + assert row is not None, "Converter 3/4 row not found" + assert row["status"] == "Matched", f"Expected 'Matched' but got {row['status']!r} for uninstalled converter" + + def test_installed_converter_itself_shows_installed(self): + """Converter 3/2 is installed in X2 Port 2 with matching serial β†’ 'Installed'.""" + rows = self._build_rows() + row = self._row(rows, "Converter 3/2") + assert row is not None, "Converter 3/2 row not found" + assert row["status"] == "Installed", f"Expected 'Installed' but got {row['status']!r} for installed converter" + + def test_no_stale_scope_across_multiple_siblings(self): + """ + bays_by_depth is reset for EACH sibling, so the second uninstalled + converter does not leak into a third converter's children.""" + # Add a second installed converter at X2 Port 6 and verify its SFP + # also shows correct status, unaffected by the reset for X2 Port 4. + inventory = _linecard_inventory() + [ + { + "entPhysicalIndex": 30, + "entPhysicalName": "X2 Port 6", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 6, + }, + { + "entPhysicalIndex": 31, + "entPhysicalName": "Converter 3/6", + "entPhysicalModelName": "CVR-X2-SFP", + "entPhysicalClass": "other", + "entPhysicalContainedIn": 30, + "entPhysicalSerialNum": "FDO_CVR6", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 32, + "entPhysicalName": "SFP slot 6", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 31, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 33, + "entPhysicalName": "GigabitEthernet3/22", + "entPhysicalModelName": "GLC-TE", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 32, + "entPhysicalSerialNum": "SFP6_SERIAL", + "entPhysicalParentRelPos": 1, + }, + ] + + view = _make_view() + device_bays, module_scoped_bays = _bay_setup() + module_types = _module_types() + + # Add a third installed CVR at X2 Port 6 with its own SFP 1 bay + cvr6_module = MagicMock() + cvr6_module.pk = 300 + cvr6_module.serial = "FDO_CVR6" + cvr6_module.module_type_id = 20 # matches mt_cvr.pk + + sfp1_bay_6 = MagicMock() + sfp1_bay_6.name = "SFP 1" + sfp6_installed = MagicMock() + sfp6_installed.serial = "SFP6_SERIAL" + sfp6_installed.get_absolute_url.return_value = "/modules/199/" + sfp6_installed.module_type_id = 30 # matches mt_glc_te.pk + sfp1_bay_6.installed_module = sfp6_installed + + x2p6_bay = MagicMock() + x2p6_bay.name = "X2 Port 6" + x2p6_bay.installed_module = cvr6_module + + module_scoped_bays[100]["X2 Port 6"] = x2p6_bay + module_scoped_bays[300] = {"SFP 1": sfp1_bay_6} + + rows = _run_build_context(view, inventory, device_bays, module_scoped_bays, module_types) + + def _row(name): + return next((r for r in rows if r.get("name") == name), None) + + # The GE3/22 under the 3rd converter (installed) should be Installed + row6 = _row("GigabitEthernet3/22") + assert row6 is not None, "GigabitEthernet3/22 not found" + assert row6["status"] == "Installed", ( + f"Expected 'Installed' but got {row6['status']!r} β€” " + "GLC-TE under installed Converter 3/6 should be Installed" + ) + # And GE3/15 under the uninstalled converter is still No Bay + row15 = _row("GigabitEthernet3/15") + assert row15["status"] == "No Bay", f"GigabitEthernet3/15 status {row15['status']!r} β€” should still be No Bay" + + +# --------------------------------------------------------------------------- +# Production-shape inventory factories +# --------------------------------------------------------------------------- + + +def _prod_inventory_ws_x4908(): + """ + Inventory shape captured from a live Cisco WS-X4908-10GE linecard + (NetBox device prod-lab03-sw4 / LibreNMS production:7). + + Real LibreNMS naming with anonymized indices: + chassis "Switch System" + container "Slot 3" [no model] + module "Linecard(slot 3)" [WS-X4908-10GE] + container "Port Container 3/2" [no model, relPos=3] + other "Converter 3/2" [CVR-X2-SFP, relPos=1] + container "Port Container 3/11" [no model, relPos=9] + port "GigabitEthernet3/11" [GLC-TE, relPos=1] + container "Port Container 3/12" [no model, relPos=10] + port "GigabitEthernet3/12" [GLC-T, relPos=1] + + Distinct from `_linecard_inventory` whose container names ("Slot 3", + "X2 Port 2", "SFP slot") match NetBox bays directly and never exercise + the contrib regex paths. This fixture forces matching through: + - `^Linecard\\(slot (\\d+)\\)$` regex for the linecard itself + - `^Port Container (\\d+)/(\\d+)$` regex for X2 slot resolution + - positional fallback for SFP transceivers inside the CVR + """ + return [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Switch System", + "entPhysicalModelName": "MIDPLANE", + "entPhysicalClass": "chassis", + "entPhysicalContainedIn": 0, + "entPhysicalSerialNum": "S_CHASSIS", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 4, + "entPhysicalName": "Slot 3", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 3, + }, + { + "entPhysicalIndex": 3000, + "entPhysicalName": "Linecard(slot 3)", + "entPhysicalModelName": "WS-X4908-10GE", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 4, + "entPhysicalSerialNum": "S_LINECARD", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 3003, + "entPhysicalName": "Port Container 3/2", + "entPhysicalDescr": "Port Container", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 3000, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 3, + }, + { + "entPhysicalIndex": 3019, + "entPhysicalName": "Converter 3/2", + "entPhysicalDescr": "Converter Module", + "entPhysicalModelName": "CVR-X2-SFP", + "entPhysicalClass": "other", + "entPhysicalContainedIn": 3003, + "entPhysicalSerialNum": "S_CVR2", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 3028, + "entPhysicalName": "Port Container 3/11", + "entPhysicalDescr": "Port Container", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 3019, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 9, + }, + { + "entPhysicalIndex": 3044, + "entPhysicalName": "GigabitEthernet3/11", + "entPhysicalDescr": "1000BaseT", + "entPhysicalModelName": "GLC-TE", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 3028, + "entPhysicalSerialNum": "MTC213403BB", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 3029, + "entPhysicalName": "Port Container 3/12", + "entPhysicalDescr": "Port Container", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 3019, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 10, + }, + { + "entPhysicalIndex": 3045, + "entPhysicalName": "GigabitEthernet3/12", + "entPhysicalDescr": "1000BaseT", + "entPhysicalModelName": "GLC-T", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 3029, + "entPhysicalSerialNum": "GE12_SERIAL", + "entPhysicalParentRelPos": 1, + }, + ] + + +def _prod_bay_setup_ws_x4908(cvr_installed=True): + """ + NetBox bay structure mirroring prod-lab03-sw4: + Device-bays: Slot 3 (linecard installed) + WS-X4908-10GE bays: X2 Port 1..8 (X2 Port 2 holds CVR if cvr_installed) + CVR-X2-SFP bays: SFP 1, SFP 2 (none installed) + """ + linecard_module = MagicMock() + linecard_module.pk = 100 + linecard_module.serial = "S_LINECARD" + linecard_module.module_type_id = 10 + + cvr2_module = MagicMock() + cvr2_module.pk = 200 + cvr2_module.serial = "S_CVR2" + cvr2_module.module_type_id = 20 + + slot3_bay = MagicMock() + slot3_bay.name = "Slot 3" + slot3_bay.installed_module = linecard_module + slot3_bay.get_absolute_url.return_value = "/bay/slot3" + device_bays = {"Slot 3": slot3_bay} + + linecard_bays = {} + for n in range(1, 9): + b = MagicMock() + b.name = f"X2 Port {n}" + b.installed_module = cvr2_module if (n == 2 and cvr_installed) else None + b.get_absolute_url.return_value = f"/bay/x2-{n}" + linecard_bays[f"X2 Port {n}"] = b + + module_scoped_bays = {100: linecard_bays} + + if cvr_installed: + cvr_bays = {} + for n in range(1, 3): + b = MagicMock() + b.name = f"SFP {n}" + b.installed_module = None + b.get_absolute_url.return_value = f"/bay/sfp-{n}" + cvr_bays[f"SFP {n}"] = b + module_scoped_bays[200] = cvr_bays + + return device_bays, module_scoped_bays + + +def _prod_module_types(): + mt_lc = MagicMock() + mt_lc.pk = 10 + mt_lc.model = "WS-X4908-10GE" + mt_lc.get_absolute_url.return_value = "/mt/lc" + mt_cvr = MagicMock() + mt_cvr.pk = 20 + mt_cvr.model = "CVR-X2-SFP" + mt_cvr.get_absolute_url.return_value = "/mt/cvr" + mt_glc_te = MagicMock() + mt_glc_te.pk = 30 + mt_glc_te.model = "GLC-TE" + mt_glc_te.get_absolute_url.return_value = "/mt/glc-te" + mt_glc_t = MagicMock() + mt_glc_t.pk = 40 + mt_glc_t.model = "GLC-T" + mt_glc_t.get_absolute_url.return_value = "/mt/glc-t" + return { + "WS-X4908-10GE": mt_lc, + "CVR-X2-SFP": mt_cvr, + "GLC-TE": mt_glc_te, + "GLC-T": mt_glc_t, + } + + +class TestProdShapeWS4908Matching: + """ + Bay matching against real production data shape from a Cisco WS-X4908-10GE. + + Distinct from `TestBayDepthScopeWithUninstalledParent`, whose synthetic + container names match bay names directly without exercising the contrib + regex paths. This class loads the contrib YAML and asserts each level + of the chain β€” linecard regex, X2 slot regex, and CVR-internal positional + fallback β€” actually does what the contrib mappings claim. + """ + + def _build_rows(self, cvr_installed=True): + view = _make_view() + device_bays, module_scoped_bays = _prod_bay_setup_ws_x4908(cvr_installed=cvr_installed) + return _run_build_context( + view, + _prod_inventory_ws_x4908(), + device_bays, + module_scoped_bays, + _prod_module_types(), + bay_mappings=_load_contrib_bay_mappings(), + ) + + def _row(self, rows, name): + for r in rows: + if r.get("name") == name: + return r + return None + + def test_linecard_matches_slot_via_regex(self): + """`Linecard(slot 3)` resolves to device-bay `Slot 3` via the Linecard regex.""" + rows = self._build_rows() + row = self._row(rows, "Linecard(slot 3)") + assert row is not None, "Linecard(slot 3) row not found" + assert row["module_bay"] == "Slot 3", ( + f"Expected module_bay='Slot 3' but got {row['module_bay']!r} β€” " + r"the `^Linecard\(slot (\d+)\)$` regex should resolve to `Slot N`" + ) + + def test_converter_matches_x2_port_via_parent_regex(self): + """`Converter 3/2`'s parent `Port Container 3/2` resolves to `X2 Port 2`.""" + rows = self._build_rows() + row = self._row(rows, "Converter 3/2") + assert row is not None, "Converter 3/2 row not found" + assert row["module_bay"] == "X2 Port 2", ( + f"Expected module_bay='X2 Port 2' but got {row['module_bay']!r} β€” " + r"parent name `Port Container 3/2` should regex-resolve to `X2 Port \2` = X2 Port 2" + ) + + def test_ge_matches_sfp1_via_positional_fallback(self): + """ + `GigabitEthernet3/11` (first port-container child of Converter 3/2) matches + `SFP 1` on the CVR module via positional fallback. + + The positional fallback indexes by **sibling order within the parent CVR**, + not by global port number β€” `Port Container 3/11` is the 1st child of + Converter 3/2, so it maps to `SFP 1`. + """ + rows = self._build_rows() + row = self._row(rows, "GigabitEthernet3/11") + assert row is not None, "GigabitEthernet3/11 row not found" + assert row["module_bay"] == "SFP 1", ( + f"Expected module_bay='SFP 1' but got {row['module_bay']!r} β€” " + "positional fallback should map the 1st port-container child of CVR-X2-SFP to SFP 1" + ) + + def test_ge_second_port_matches_sfp2_via_positional_fallback(self): + """`GigabitEthernet3/12` (2nd port-container child of CVR) matches `SFP 2`.""" + rows = self._build_rows() + row = self._row(rows, "GigabitEthernet3/12") + assert row is not None, "GigabitEthernet3/12 row not found" + assert row["module_bay"] == "SFP 2", ( + f"Expected module_bay='SFP 2' but got {row['module_bay']!r} β€” " + "positional fallback should map the 2nd port-container child of CVR-X2-SFP to SFP 2" + ) + + def test_ge_no_bay_when_cvr_not_installed_in_netbox(self): + """ + When the CVR is matched (X2 Port 2) but no module is installed there in + NetBox, the deeper SFP scope is empty and GE inside the CVR shows 'No Bay'. + + This is the original confusion that triggered the reverted commit + (216fb84): 'GE3/11 doesn't match a bay' was actually 'CVR module not + installed in NetBox' β€” the fix is to install the module, not to walk + ancestor names looking for a wrong bay to land on. + """ + rows = self._build_rows(cvr_installed=False) + row = self._row(rows, "GigabitEthernet3/11") + assert row is not None, "GigabitEthernet3/11 row not found" + assert row["module_bay"] == "-", ( + f"Expected no bay match (got {row['module_bay']!r}) β€” " + "without an installed CVR there is no SFP scope for positional fallback" + ) + assert row["status"] == "No Bay", f"Expected status='No Bay' but got {row['status']!r}" + + def test_no_cvr_entry_does_not_match_via_grandparent_walking(self): + """ + Regression guard for reverted commit 216fb84. + + Some Cisco devices expose only the Port-Container chain in ENTITY-MIB + (no Converter entry between linecard and port). The hierarchy is: + + Linecard(slot 3) + Port Container 3/2 [no model β€” skipped, no row] + Port Container 3/11 [no model β€” skipped, no row] + GigabitEthernet3/11 [GLC-TE] + + Because both intermediate containers are model-less, no row updates the + bay scope and GE3/11 inherits the linecard's bays as scope. In this + scope: + - immediate-parent regex `Port Container 3/11` β†’ `X2 Port 11` (no such bay) + - grandparent regex `Port Container 3/2` β†’ `X2 Port 2` (the bay holding + the CVR module, not a transceiver bay) + + Correct behavior: no bay match. The reverted commit's "walk all + ancestors" logic resolved GE3/11 to `X2 Port 2`, semantically landing + the transceiver in the parent module's slot. + """ + no_cvr_inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Switch System", + "entPhysicalModelName": "MIDPLANE", + "entPhysicalClass": "chassis", + "entPhysicalContainedIn": 0, + "entPhysicalSerialNum": "S_CHASSIS", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 4, + "entPhysicalName": "Slot 3", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 3, + }, + { + "entPhysicalIndex": 3000, + "entPhysicalName": "Linecard(slot 3)", + "entPhysicalModelName": "WS-X4908-10GE", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 4, + "entPhysicalSerialNum": "S_LINECARD", + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 3003, + "entPhysicalName": "Port Container 3/2", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 3000, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 3, + }, + { + "entPhysicalIndex": 3028, + "entPhysicalName": "Port Container 3/11", + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 3003, + "entPhysicalSerialNum": "", + "entPhysicalParentRelPos": 9, + }, + { + "entPhysicalIndex": 3044, + "entPhysicalName": "GigabitEthernet3/11", + "entPhysicalModelName": "GLC-TE", + "entPhysicalClass": "port", + "entPhysicalContainedIn": 3028, + "entPhysicalSerialNum": "MTC213403BB", + "entPhysicalParentRelPos": 1, + }, + ] + view = _make_view() + device_bays, module_scoped_bays = _prod_bay_setup_ws_x4908(cvr_installed=True) + rows = _run_build_context( + view, + no_cvr_inventory, + device_bays, + module_scoped_bays, + _prod_module_types(), + bay_mappings=_load_contrib_bay_mappings(), + ) + row = self._row(rows, "GigabitEthernet3/11") + assert row is not None, "GigabitEthernet3/11 row not found" + assert row["module_bay"] != "X2 Port 2", ( + "GE3/11 matched X2 Port 2 β€” that bay holds the parent CVR module, " + "not a transceiver. An ancestor-walking matcher (reverted 216fb84) " + "would resolve `Port Container 3/2` (grandparent) to `X2 Port 2` " + "and incorrectly land the transceiver in the CVR's own bay." + ) + + +class TestPositionalMatchScaffoldingChain: + """ + Regression coverage for `_match_bay_by_position` walking through deep + Cisco IOS-XR scaffolding (module ancestors with model="N/A"). + + Captured shape from a Cisco ASR-9904 (NetBox device prod-lab03d-ra1.lab, + LibreNMS production:30) β€” TenGigE ports inside a 24x10GE linecard: + + chassis "Rack 0" [ASR-9904] + container "Rack 0-Line Card Slot 0" [no model] + module "0/0" [A9K-24X10GE-1G-TR] + module "0/0-Motherboard" [N/A] + module "0/0-Slice 0" [N/A] + module "0/0-Slice 0 EZChip" [N/A] + module "Slice 0 SFP Port Module #N" [N/A] + container "0/0-SFP+ bay N" [N/A] + module "TenGigE0/0/0/N" [SFP-10G-SR] + + The 0/0 linecard's serial accidentally matches the device serial + (LibreNMS reports it that way for some IOS-XR units), which fires the + `Embedded RP / fixed-chassis system board` ignore rule with + action=transparent. As a result the linecard is hidden and every + TenGigE port is promoted to top-level β€” matched against the device's + bays {Slot 0, Slot 1, Slot 2, Slot 3}. + + Bug pre-fix: the positional walk in `_match_bay_by_position` skipped + every modelless ancestor regardless of class, eventually landing + container_idx on `Motherboard` (the deepest item before the real-model + `0/0`). Every TenGigE port walked to the same Motherboard, took + position=1 inside `0/0`'s children, and matched `Slot 1` on the chassis + β€” the bay where the RSP0 line card belongs. Clicking install would + place SFP transceivers into the chassis line-card slots. + + Fix: stop the walk on a non-container ancestor without a real model. + Modelless modules ("Motherboard", "Slice 0", "EZChip" et al.) are + scaffolding, not bay positions; treating them as walk-through containers + silently collapses sibling counts. After the fix the positional matcher + returns None and the row shows "No Bay". + """ + + def _scaffolding_inventory(self): + return [ + { + "entPhysicalIndex": 8384513, + "entPhysicalName": "Rack 0", + "entPhysicalModelName": "ASR-9904", + "entPhysicalClass": "chassis", + "entPhysicalContainedIn": 0, + "entPhysicalSerialNum": "FOX2128PLQ8", + "entPhysicalParentRelPos": -1, + }, + { + "entPhysicalIndex": 8384552, + "entPhysicalName": "Rack 0-Line Card Slot 0", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 8384513, + "entPhysicalParentRelPos": 3, + }, + { + "entPhysicalIndex": 1, + "entPhysicalName": "0/0", + "entPhysicalModelName": "A9K-24X10GE-1G-TR", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 8384552, + "entPhysicalSerialNum": "DEVICE_SERIAL", # matches device serial below + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 30, + "entPhysicalName": "0/0-Motherboard", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 35, + "entPhysicalName": "0/0-Slice 0", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 30, + "entPhysicalParentRelPos": 4, + }, + { + "entPhysicalIndex": 330, + "entPhysicalName": "0/0-Slice 0 EZChip", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 35, + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 601, + "entPhysicalName": "0/0-Slice 0 SFP Port Module #0", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 330, + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 801, + "entPhysicalName": "0/0-SFP+ bay 0", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 601, + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 409601, + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalModelName": "SFP-10G-SR", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 801, + "entPhysicalSerialNum": "SFP_SERIAL_0", + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 602, + "entPhysicalName": "0/0-Slice 0 SFP Port Module #1", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 330, + "entPhysicalParentRelPos": 1, + }, + { + "entPhysicalIndex": 802, + "entPhysicalName": "0/0-SFP+ bay 1", + "entPhysicalModelName": "N/A", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 602, + "entPhysicalParentRelPos": 0, + }, + { + "entPhysicalIndex": 413697, + "entPhysicalName": "TenGigE0/0/0/1", + "entPhysicalModelName": "SFP-10G-SR", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 802, + "entPhysicalSerialNum": "SFP_SERIAL_1", + "entPhysicalParentRelPos": 0, + }, + ] + + def _device_bays(self): + bays = {} + for n in range(0, 4): + b = MagicMock() + b.name = f"Slot {n}" + b.installed_module = None + b.get_absolute_url.return_value = f"/bay/slot-{n}" + bays[f"Slot {n}"] = b + return bays + + def _module_types(self): + mt = MagicMock() + mt.pk = 50 + mt.model = "SFP-10G-SR" + mt.get_absolute_url.return_value = "/mt/sfp" + return {"SFP-10G-SR": mt} + + def _build_rows(self, device_serial="DEVICE_SERIAL"): + view = _make_view() + # Need a transparent rule that fires on serial_matches_device, like prod + from netbox_librenms_plugin.tests.test_modules_view import _make_view as _mv # noqa: F401 + + rows_store = _captured_table_view(view) + view._get_module_bays = MagicMock(return_value=(self._device_bays(), {})) + view._get_module_types = MagicMock(return_value=self._module_types()) + view._get_generic_module_types = MagicMock(return_value={}) + view._get_module_type_ambiguities = MagicMock(return_value={}) + view._get_carrier_install_rules = MagicMock(return_value=[]) + + # Device-serial matches the linecard's serial β†’ linecard becomes transparent + device = MagicMock() + device.serial = device_serial + device.virtual_chassis = None + device.id = 1 + device_type = MagicMock() + device_type.manufacturer = None + device.device_type = device_type + + # Build a fake "transparent" ignore rule matching serial_matches_device + transparent_rule = MagicMock() + transparent_rule.match_type = "serial_matches_device" + transparent_rule.action = "transparent" + transparent_rule.require_serial_match_parent = False + + with ( + patch("netbox_librenms_plugin.views.base.modules_view.cache") as mock_cache, + patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])), + patch("netbox_librenms_plugin.utils.get_enabled_ignore_rules", return_value=[transparent_rule]), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda v, *a, **kw: v), + patch("netbox_librenms_plugin.utils.preload_normalization_rules", return_value={}), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch.object(view.__class__, "_detect_serial_conflicts", return_value=None), + ): + mock_cache.ttl = MagicMock(return_value=None) + view._build_context(MagicMock(), device, self._scaffolding_inventory()) + return rows_store.get("rows", []) + + def _row(self, rows, name): + for r in rows: + if r.get("name") == name: + return r + return None + + def test_tengig_does_not_match_chassis_slot_via_scaffolding_walk(self): + """ + TenGigE ports at the bottom of a model="N/A" module chain must NOT be + positional-matched to a chassis bay. Pre-fix, every TenGigE walked + through Motherboard/Slice/EZChip and landed on Slot 1 (the RSP bay). + """ + rows = self._build_rows() + for name in ("TenGigE0/0/0/0", "TenGigE0/0/0/1"): + row = self._row(rows, name) + assert row is not None, f"{name} row not found" + assert row["module_bay"] != "Slot 1", ( + f"{name} matched 'Slot 1' on the chassis β€” that bay holds the " + "RSP line card, not a transceiver. Positional fallback walked " + "through model-less module-class scaffolding (Motherboard, " + "Slice 0, EZChip, SFP Port Module) before stopping at the " + "0/0 linecard, conflating every TenGigE to position=1." + ) + + def test_tengig_shows_no_bay_when_only_scaffolding_above(self): + """ + With no real position-container chain and no bay templates on the + scaffolding modules' types, transceivers should resolve to 'No Bay'. + """ + rows = self._build_rows() + row = self._row(rows, "TenGigE0/0/0/0") + assert row is not None + assert row["status"] == "No Bay", ( + f"Expected 'No Bay' but got {row['status']!r}. With only chassis " + "Slot 0..3 bays in scope and modelless module scaffolding above the " + "transceiver, the positional fallback should bail rather than " + "confidently mismatching." + ) + + def test_tengig_siblings_resolve_independently(self): + """ + Each TenGigE port must be evaluated independently. Pre-fix all ports + collapsed to the same `container_idx` and got identical (wrong) bays. + """ + rows = self._build_rows() + bays = {r.get("name"): r.get("module_bay") for r in rows if r.get("name", "").startswith("TenGigE")} + # Either both resolve to "-" (no bay) or to distinct bays. They must + # NOT all share the same chassis bay. + non_dash = [b for b in bays.values() if b and b != "-"] + assert len(set(non_dash)) == len(non_dash), ( + f"TenGigE ports collapsed to duplicate bay assignments: {bays}. " + "Positional fallback walked through scaffolding and produced the " + "same container_idx for siblings that have different physical positions." + ) + + +class TestCollectDescendants: + """Tests for _collect_descendants depth tracking.""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + return object.__new__(BaseModuleTableView) + + def test_empty_container_children_at_same_depth(self): + """Children of a no-model container are returned at the same depth as the container.""" + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "", "entPhysicalContainedIn": 0}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "REAL-MODULE", "entPhysicalContainedIn": 1}, + ] + children_by_parent = {} + index_map = {} + for item in inventory: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + idx = item.get("entPhysicalIndex") + if idx is not None: + index_map[idx] = item + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, ignore_rules=[], depth=1, results=results) + assert len(results) == 1 + depth, item = results[0] + assert depth == 1, "Child of modelless container must be at the same depth" + assert item["entPhysicalModelName"] == "REAL-MODULE" + + def test_model_children_at_incremented_depth(self): + """Children of a model-bearing item are at depth+1.""" + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "CHILD", "entPhysicalContainedIn": 1}, + ] + children_by_parent = {} + index_map = {} + for item in inventory: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + idx = item.get("entPhysicalIndex") + if idx is not None: + index_map[idx] = item + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, ignore_rules=[], depth=1, results=results) + depths = [d for d, _ in results] + assert depths == [1, 2], f"Expected [1, 2] but got {depths}" + + +class TestDetermineStatus: + """Tests for _determine_status logic.""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + return object.__new__(BaseModuleTableView) + + def test_matched_bay_and_type(self): + import types + + view = self._view() + assert view._determine_status(types.SimpleNamespace(id=1), types.SimpleNamespace(id=1), "S1") == "Matched" + + def test_no_bay(self): + import types + + view = self._view() + assert view._determine_status(None, types.SimpleNamespace(id=1), "S1") == "No Bay" + + def test_no_type(self): + import types + + view = self._view() + assert view._determine_status(types.SimpleNamespace(id=1), None, "S1") == "No Type" + + def test_unmatched_fallback(self): + view = self._view() + assert view._determine_status(None, None, "S1") == "No Bay" + + +class TestBuildRowSerialMismatch: + """Tests for serial mismatch detection and can_update_serial flag in _build_row.""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + return view + + def _make_bay(self, installed_serial=None, module_type_id=5): + """Create a mock bay with an optionally installed module.""" + bay = MagicMock() + bay.pk = 10 + bay.name = "Slot 1" + bay.get_absolute_url.return_value = "/dcim/module-bays/10/" + if installed_serial is not None: + module = MagicMock() + module.pk = 42 + module.serial = installed_serial + module.module_type_id = module_type_id + module.get_absolute_url.return_value = "/dcim/modules/42/" + bay.installed_module = module + else: + bay.installed_module = None + return bay + + def _make_item(self, model_name="XCM-7s-b", serial="NS225161205"): + return { + "entPhysicalModelName": model_name, + "entPhysicalSerialNum": serial, + "entPhysicalName": "Slot 1", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalIndex": 100, + } + + def test_serial_match_sets_installed_status(self): + """When ENTITY-MIB serial matches NetBox serial, status is Installed.""" + view = self._view() + bay = self._make_bay(installed_serial="NS225161205") + matched_type = MagicMock() + matched_type.model = "XCM-7s-b" + matched_type.pk = 5 + matched_type.get_absolute_url.return_value = "/dcim/module-types/5/" + + with ( + patch.object(view, "_match_module_bay", return_value=bay), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s-b"), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + ): + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Installed" + assert "row_class" not in row + assert not row.get("can_update_serial") + + def test_installed_row_sets_update_interface_when_template_matches_exist(self): + """Installed non-port rows expose Update Interface when standalone template matches exist.""" + view = self._view() + bay = self._make_bay(installed_serial="NS225161205") + matched_type = MagicMock() + matched_type.model = "XCM-7s-b" + matched_type.pk = 5 + matched_type.get_absolute_url.return_value = "/dcim/module-types/5/" + + with ( + patch.object(view, "_match_module_bay", return_value=bay), + patch.object(view, "_count_adoptable_template_interfaces", return_value=2), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s-b"), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + ): + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Installed" + assert row["can_update_interface_binding"] is True + assert row["adoptable_interface_count"] == 2 + + def test_count_adoptable_template_interfaces_uses_vc_aware_names(self): + view = self._view() + device = MagicMock() + device.vc_position = 3 + device.virtual_chassis_id = 11 + device.virtual_chassis = MagicMock() + device.virtual_chassis.members.values_list.return_value = [1, 2, 3] + + module = MagicMock() + module.device = device + template = MagicMock() + instantiated = MagicMock() + instantiated.name = "TenGigabitEthernet1/1/1" + template.instantiate.return_value = instantiated + module.module_type.interfacetemplates.all.return_value = [template] + + with patch("dcim.models.Interface") as mock_interface: + mock_interface.objects.filter.return_value.count.return_value = 1 + result = view._count_adoptable_template_interfaces(module) + + assert result == 1 + mock_interface.objects.filter.assert_called_once_with( + device=device, + module__isnull=True, + name__in=["TenGigabitEthernet3/1/1"], + ) + + def test_serial_mismatch_sets_can_update_serial(self): + """When serials differ, can_update_serial=True and installed_module_id set.""" + view = self._view() + bay = self._make_bay(installed_serial="TESTSRL") + matched_type = MagicMock() + matched_type.model = "XCM-7s-b" + matched_type.pk = 5 + matched_type.get_absolute_url.return_value = "/dcim/module-types/5/" + + with ( + patch.object(view, "_match_module_bay", return_value=bay), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s-b"), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + ): + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Serial Mismatch" + assert "row_class" not in row + assert row.get("can_update_serial") is True + assert row.get("installed_module_id") == 42 + + def test_empty_netbox_serial_flags_mismatch(self): + """When NetBox serial is empty but LibreNMS has one, status is Serial Mismatch.""" + view = self._view() + bay = self._make_bay(installed_serial="") + matched_type = MagicMock() + matched_type.model = "XCM-7s-b" + matched_type.pk = 5 + matched_type.get_absolute_url.return_value = "/dcim/module-types/5/" + + with ( + patch.object(view, "_match_module_bay", return_value=bay), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="XCM-7s-b"), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + ): + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Serial Mismatch" + assert row.get("can_update_serial") + assert row.get("can_replace") + + def _common_patches(self, view, bay, matched_type_name): + """Return a stack of common patches for _build_row helper calls.""" + from unittest.mock import patch + + return [ + patch.object(view, "_match_module_bay", return_value=bay), + patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value=matched_type_name), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + ] + + def _make_matched_type(self, model_name, pk=5): + matched_type = MagicMock() + matched_type.model = model_name + matched_type.pk = pk + matched_type.get_absolute_url.return_value = f"/dcim/module-types/{pk}/" + return matched_type + + def test_type_mismatch_sets_type_mismatch_status(self): + """When installed module type differs from LibreNMS type, status is Type Mismatch.""" + view = self._view() + bay = self._make_bay(installed_serial="S1") + # Installed type pk=99, matched type pk=5 β€” different + bay.installed_module.module_type_id = 99 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(model_name="XCM-7s-b", serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Type Mismatch" + assert "row_class" not in row + + def test_type_mismatch_sets_can_replace(self): + """Type Mismatch row has can_replace=True and installed_module_id set.""" + view = self._view() + bay = self._make_bay(installed_serial="S1") + bay.installed_module.module_type_id = 99 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(model_name="XCM-7s-b", serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row.get("can_replace") is True + assert row.get("installed_module_id") == 42 + + def test_serial_mismatch_also_sets_can_replace(self): + """Serial Mismatch rows also get can_replace=True (same type).""" + view = self._view() + bay = self._make_bay(installed_serial="TESTSRL") + bay.installed_module.module_type_id = 5 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Serial Mismatch" + assert row.get("can_replace") is True + assert row.get("can_update_serial") is True + + def test_same_type_same_serial_no_replace(self): + """Clean Installed row has neither can_replace nor can_update_serial.""" + view = self._view() + bay = self._make_bay(installed_serial="NS225161205") + bay.installed_module.module_type_id = 5 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(serial="NS225161205"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Installed" + assert not row.get("can_replace") + assert not row.get("can_update_serial") + + def test_librenms_dash_serial_with_empty_installed_gives_installed(self): + """LibreNMS serial '-' normalizes to empty; both empty -> Installed, not mismatch.""" + view = self._view() + bay = self._make_bay(installed_serial="") + bay.installed_module.module_type_id = 5 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(serial="-"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Installed" + assert "row_class" not in row + assert not row.get("can_update_serial") + + def test_librenms_dash_serial_with_real_installed_gives_installed(self): + """LibreNMS serial '-' normalizes to empty; only NetBox has serial -> no mismatch.""" + view = self._view() + bay = self._make_bay(installed_serial="REAL123") + bay.installed_module.module_type_id = 5 + matched_type = self._make_matched_type("XCM-7s-b", pk=5) + + patches = self._common_patches(view, bay, "XCM-7s-b") + from contextlib import ExitStack + + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + row = view._build_row( + self._make_item(serial="-"), + {}, + {"Slot 1": bay}, + {"XCM-7s-b": matched_type}, + ) + + assert row["status"] == "Installed" + assert "row_class" not in row + assert not row.get("can_update_serial") + + +class TestDetectSerialConflicts: + """Tests for BaseModuleTableView._detect_serial_conflicts().""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + return object.__new__(BaseModuleTableView) + + def test_no_can_replace_or_install_rows_does_nothing(self): + """When no rows have can_replace or can_install, the method returns without DB query.""" + view = self._view() + table_data = [{"serial": "S1", "status": "Installed"}] + with patch("dcim.models.Module") as mock_module_cls: + view._detect_serial_conflicts(table_data) + mock_module_cls.objects.filter.assert_not_called() + assert "serial_conflict_module" not in table_data[0] + + def test_conflict_detected_for_can_replace_row(self): + """When a conflicting module exists, serial_conflict_module is set on the row.""" + view = self._view() + conflict = MagicMock() + conflict.serial = "CONFLICT_SERIAL" + conflict.pk = 999 + conflict.module_bay = MagicMock() + conflict.device = MagicMock() + + row = { + "can_replace": True, + "serial": "CONFLICT_SERIAL", + "installed_module_id": 42, # different from conflict.pk + } + + with patch("dcim.models.Module") as mock_module_cls: + mock_module_cls.objects.filter.return_value.select_related.return_value = [conflict] + view._detect_serial_conflicts([row]) + + assert row.get("serial_conflict_module") is conflict + assert row.get("can_move_from") is True + + def test_no_conflict_when_conflict_is_same_module(self): + """When the only module with the serial IS the installed module, no conflict is set.""" + view = self._view() + conflict = MagicMock() + conflict.serial = "S1" + conflict.pk = 42 # Same as installed_module_id + + row = { + "can_replace": True, + "serial": "S1", + "installed_module_id": 42, + } + + with patch("dcim.models.Module") as mock_module_cls: + mock_module_cls.objects.filter.return_value.select_related.return_value = [conflict] + view._detect_serial_conflicts([row]) + + assert "serial_conflict_module" not in row + assert not row.get("can_move_from") + + def test_conflict_detected_for_can_install_row(self): + """Serial conflicts are also detected for empty-bay (can_install) rows.""" + view = self._view() + conflict = MagicMock() + conflict.serial = "CONFLICT_SERIAL" + conflict.pk = 999 + + row = { + "can_install": True, + "serial": "CONFLICT_SERIAL", + # No installed_module_id β€” bay is empty + } + + with patch("dcim.models.Module") as mock_module_cls: + mock_module_cls.objects.filter.return_value.select_related.return_value = [conflict] + view._detect_serial_conflicts([row]) + + assert row.get("serial_conflict_module") is conflict + assert row.get("can_move_from") is True + + def test_ambiguous_when_multiple_conflicts_for_same_serial(self): + """When multiple modules share the same serial, mark the row ambiguous instead of picking one.""" + view = self._view() + conflict1 = MagicMock() + conflict1.serial = "DUP_SERIAL" + conflict1.pk = 100 + + conflict2 = MagicMock() + conflict2.serial = "DUP_SERIAL" + conflict2.pk = 200 + + row = { + "can_replace": True, + "serial": "DUP_SERIAL", + "installed_module_id": 42, + } + + with patch("dcim.models.Module") as mock_module_cls: + mock_module_cls.objects.filter.return_value.select_related.return_value = [conflict1, conflict2] + view._detect_serial_conflicts([row]) + + assert row.get("serial_conflict_module") is None + assert not row.get("can_move_from") + assert row.get("serial_conflict_ambiguous") is True + + def test_can_install_no_serial_not_flagged(self): + """A can_install row with no serial is not checked for conflicts.""" + view = self._view() + row = {"can_install": True, "serial": "-"} + with patch("dcim.models.Module") as mock_module_cls: + view._detect_serial_conflicts([row]) + mock_module_cls.objects.filter.assert_not_called() + assert "serial_conflict_module" not in row + + +class TestInventoryIgnoreRuleMatchesName: + """Tests for InventoryIgnoreRule.matches_name() β€” all four match types.""" + + def _rule(self, match_type, pattern, require_serial=True): + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.match_type = match_type + rule.pattern = pattern + rule.require_serial_match_parent = require_serial + rule.enabled = True + return rule + + # --- ends_with --- + + def test_ends_with_optics_idprom(self): + assert self._rule("ends_with", "IDPROM").matches_name("Optics0/0/0/0-IDPROM") is True + + def test_ends_with_fan_idprom(self): + assert self._rule("ends_with", "IDPROM").matches_name("0/FT0-FT IDPROM") is True + + def test_ends_with_chassis_idprom(self): + assert self._rule("ends_with", "IDPROM").matches_name("Rack 0-Chassis IDPROM") is True + + def test_ends_with_case_insensitive(self): + assert self._rule("ends_with", "IDPROM").matches_name("Optics0/0/0/0-idprom") is True + + def test_ends_with_no_match(self): + assert self._rule("ends_with", "IDPROM").matches_name("Optics0/0/0/0") is False + + def test_ends_with_idprom_in_middle(self): + assert self._rule("ends_with", "IDPROM").matches_name("IDPROM-Optics0/0/0/0") is False + + # --- starts_with --- + + def test_starts_with_match(self): + assert self._rule("starts_with", "Optics").matches_name("Optics0/0/0/0") is True + + def test_starts_with_no_match(self): + assert self._rule("starts_with", "Optics").matches_name("0/FT0") is False + + def test_starts_with_case_insensitive(self): + assert self._rule("starts_with", "OPTICS").matches_name("optics0/0/0/0") is True + + # --- contains --- + + def test_contains_match(self): + assert self._rule("contains", "IDPROM").matches_name("Rack 0-Chassis IDPROM") is True + + def test_contains_middle_match(self): + assert self._rule("contains", "IDPROM").matches_name("IDPROM-Optics0/0/0/0") is True + + def test_contains_case_insensitive(self): + assert self._rule("contains", "IDPROM").matches_name("chassis-idprom") is True + + def test_contains_no_match(self): + assert self._rule("contains", "IDPROM").matches_name("Optics0/0/0/0") is False + + # --- regex --- + + def test_regex_match(self): + assert self._rule("regex", r"-IDPROM$").matches_name("Optics0/0/0/0-IDPROM") is True + + def test_regex_no_match(self): + assert self._rule("regex", r"-IDPROM$").matches_name("Optics0/0/0/0") is False + + def test_regex_complex_pattern(self): + assert self._rule("regex", r"^0/FT\d+-FT IDPROM$").matches_name("0/FT0-FT IDPROM") is True + + # --- edge cases --- + + def test_empty_name(self): + assert self._rule("ends_with", "IDPROM").matches_name("") is False + + def test_none_name(self): + assert self._rule("ends_with", "IDPROM").matches_name(None) is False + + +class TestCheckIgnoreRules: + """Tests for the _check_ignore_rules() module-level function.""" + + def _rule(self, match_type="ends_with", pattern="IDPROM", require_serial=True, action="skip"): + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.match_type = match_type + rule.pattern = pattern + rule.require_serial_match_parent = require_serial + rule.action = action + rule.enabled = True + return rule + + def _check(self, item, parent_item, rules, index_map=None, device_serial=""): + from netbox_librenms_plugin.views.base.modules_view import _check_ignore_rules + + return _check_ignore_rules(item, parent_item, rules, index_map, device_serial) + + def test_match_with_serial_match_skips(self): + """Item matches rule name AND serial matches parent β†’ should be skipped.""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": "ABC123"} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, [self._rule()]) == "skip" + + def test_match_with_serial_mismatch_not_skipped(self): + """Name matches but serial differs from parent β†’ NOT skipped (could be real module).""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": "XYZ999"} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, [self._rule()]) is None + + def test_match_with_no_parent_not_skipped(self): + """Name matches, require_serial=True, but no parent β†’ conservative: NOT skipped.""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, None, [self._rule()]) is None + + def test_match_no_serial_require_false_skips(self): + """require_serial_match_parent=False β†’ skipped on name match alone.""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": ""} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, [self._rule(require_serial=False)]) == "skip" + + def test_no_matching_rule_not_skipped(self): + """Name does not match any rule β†’ NOT skipped.""" + item = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + parent = {"entPhysicalName": "Rack 0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, [self._rule()]) is None + + def test_empty_rules_not_skipped(self): + """Empty rules list β†’ nothing skipped.""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": "ABC123"} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, []) is None + + def test_item_serial_empty_not_skipped_when_serial_required(self): + """Item has empty serial β†’ can't confirm match β†’ NOT skipped.""" + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": ""} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, parent, [self._rule()]) is None + + def test_first_matching_rule_wins(self): + """First rule that matches and satisfies serial check is used; later rules ignored.""" + rule_skip = self._rule(require_serial=False) + rule_serial = self._rule(require_serial=True) + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": ""} + parent = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "ABC123"} + # rule_skip (require_serial=False) matches first β†’ should skip + assert self._check(item, parent, [rule_skip, rule_serial]) == "skip" + + def test_ancestor_walk_skips_when_grandparent_serial_matches(self): + """IOS-XR case: IDPROM is child of empty-serial Mother Board, but grandparent serial matches.""" + # Mirrors actual 8201-SYS data: 0/RP0/CPU0-Base Board IDPROM (idx=7) + # parent=Mother Board (idx=30, serial=''), grandparent=0/RP0/CPU0 (idx=1, serial='FOC2418NHRK') + grandparent = { + "entPhysicalIndex": 1, + "entPhysicalName": "0/RP0/CPU0", + "entPhysicalSerialNum": "FOC2418NHRK", + "entPhysicalContainedIn": 0, + } + parent = { + "entPhysicalIndex": 30, + "entPhysicalName": "0/RP0/CPU0-Mother Board", + "entPhysicalSerialNum": "", + "entPhysicalContainedIn": 1, + } + item = { + "entPhysicalIndex": 7, + "entPhysicalName": "0/RP0/CPU0-Base Board IDPROM", + "entPhysicalSerialNum": "FOC2418NHRK", + "entPhysicalContainedIn": 30, + } + index_map = {1: grandparent, 30: parent, 7: item} + assert self._check(item, parent, [self._rule()], index_map=index_map) == "skip" + + def test_ancestor_walk_stops_at_non_matching_serial(self): + """Ancestor walk stops at first non-empty serial; if it doesn't match β†’ NOT skipped.""" + grandparent = { + "entPhysicalIndex": 1, + "entPhysicalName": "Chassis", + "entPhysicalSerialNum": "DIFFERENT_SN", + "entPhysicalContainedIn": 0, + } + parent = { + "entPhysicalIndex": 30, + "entPhysicalName": "Board", + "entPhysicalSerialNum": "", + "entPhysicalContainedIn": 1, + } + item = { + "entPhysicalIndex": 7, + "entPhysicalName": "Board-IDPROM", + "entPhysicalSerialNum": "FOC2418NHRK", + "entPhysicalContainedIn": 30, + } + index_map = {1: grandparent, 30: parent, 7: item} + assert self._check(item, parent, [self._rule()], index_map=index_map) is None + + def test_serial_matches_device_transparent(self): + """serial_matches_device rule with action=transparent returns 'transparent'.""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "0/RP0/CPU0", "entPhysicalSerialNum": "FOC2418NHRK", "entPhysicalIndex": 5} + assert self._check(item, None, [rule], device_serial="FOC2418NHRK") == "transparent" + + def test_serial_matches_device_skip(self): + """serial_matches_device rule with action=skip returns 'skip'.""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="skip") + item = {"entPhysicalName": "0/RP0/CPU0", "entPhysicalSerialNum": "FOC2418NHRK"} + assert self._check(item, None, [rule], device_serial="FOC2418NHRK") == "skip" + + def test_serial_matches_device_no_match(self): + """serial_matches_device: item serial differs from device serial β†’ no match.""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "Optics0/0/0/0", "entPhysicalSerialNum": "XCVR001"} + assert self._check(item, None, [rule], device_serial="FOC2418NHRK") is None + + def test_serial_matches_device_empty_device_serial(self): + """serial_matches_device: device serial empty β†’ no match (defensive).""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "0/RP0/CPU0", "entPhysicalSerialNum": "FOC2418NHRK"} + assert self._check(item, None, [rule], device_serial="") is None + + def test_serial_matches_device_empty_item_serial(self): + """serial_matches_device: item serial empty β†’ no match (defensive).""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "0/RP0/CPU0", "entPhysicalSerialNum": ""} + assert self._check(item, None, [rule], device_serial="FOC2418NHRK") is None + + def test_serial_matches_device_fires_when_parent_is_chassis(self): + """serial_matches_device: matches when direct parent has class='chassis'.""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "0/RP0/CPU0", "entPhysicalSerialNum": "FOC2418NHRK"} + chassis = {"entPhysicalName": "Rack 0", "entPhysicalClass": "chassis"} + assert self._check(item, chassis, [rule], device_serial="FOC2418NHRK") == "transparent" + + def test_serial_matches_device_skipped_when_parent_is_container(self): + """ + serial_matches_device: does NOT match when parent is a container + (e.g. ASR-9904 line card whose serial happens to equal the device + serial β€” the linecard is contained in a 'Line Card Slot N' container, + not the chassis). Treating it as transparent would silently promote + its TenGigE children to chassis-level bay matching. + """ + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "0/0", "entPhysicalSerialNum": "FOC2349N4UN"} + slot_container = {"entPhysicalName": "Rack 0-Line Card Slot 0", "entPhysicalClass": "container"} + assert self._check(item, slot_container, [rule], device_serial="FOC2349N4UN") is None + + def test_serial_matches_device_skipped_when_parent_is_module(self): + """serial_matches_device: does NOT match when parent is a module.""" + rule = self._rule(match_type="serial_matches_device", pattern="", action="transparent") + item = {"entPhysicalName": "Submodule", "entPhysicalSerialNum": "ABC123"} + parent_module = {"entPhysicalName": "Parent", "entPhysicalClass": "module"} + assert self._check(item, parent_module, [rule], device_serial="ABC123") is None + + def test_transparent_action_returned_for_name_rule(self): + """A name-based rule with action=transparent returns 'transparent'.""" + rule = self._rule(match_type="ends_with", pattern="IDPROM", require_serial=False, action="transparent") + item = {"entPhysicalName": "Optics0/0/0/0-IDPROM", "entPhysicalSerialNum": "ABC123"} + assert self._check(item, None, [rule]) == "transparent" + + +class TestCollectDescendantsIgnoreRules: + """_collect_descendants must skip items matched by ignore rules.""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + return object.__new__(BaseModuleTableView) + + def _rule(self, match_type="ends_with", pattern="IDPROM", require_serial=True, action="skip"): + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.match_type = match_type + rule.pattern = pattern + rule.require_serial_match_parent = require_serial + rule.action = action + rule.enabled = True + return rule + + def _build_maps(self, inventory): + children_by_parent = {} + index_map = {} + for item in inventory: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + idx = item.get("entPhysicalIndex") + if idx is not None: + index_map[idx] = item + return children_by_parent, index_map + + def test_idprom_child_is_excluded(self): + """IDPROM child of a real module must not appear in results.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Optics0/0/0/0", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalName": "Optics0/0/0/0-IDPROM", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 1, + }, + ] + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [self._rule()], depth=1, results=results) + assert len(results) == 1 + _, item = results[0] + assert item["entPhysicalName"] == "Optics0/0/0/0" + + def test_idprom_child_descendants_also_excluded(self): + """Nothing nested below a skipped entry should appear either.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Optics0/0/0/0", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalName": "Optics0/0/0/0-IDPROM", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 1, + }, + { + "entPhysicalIndex": 3, + "entPhysicalName": "Optics0/0/0/0-IDPROM-SubItem", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 2, + }, + ] + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [self._rule()], depth=1, results=results) + names = [item["entPhysicalName"] for _, item in results] + assert "Optics0/0/0/0" in names + assert "Optics0/0/0/0-IDPROM" not in names + assert "Optics0/0/0/0-IDPROM-SubItem" not in names + + def test_real_submodule_still_included(self): + """A legitimate non-matching child remains in results.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "0/FT0", + "entPhysicalModelName": "FAN-1RU-PI", + "entPhysicalSerialNum": "SER002", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalName": "0/FT0-FT IDPROM", + "entPhysicalModelName": "FAN-1RU-PI", + "entPhysicalSerialNum": "SER002", + "entPhysicalContainedIn": 1, + }, + { + "entPhysicalIndex": 3, + "entPhysicalName": "FanBlade-0", + "entPhysicalModelName": "BLADE-A", + "entPhysicalSerialNum": "SER003", + "entPhysicalContainedIn": 1, + }, + ] + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [self._rule()], depth=1, results=results) + names = [item["entPhysicalName"] for _, item in results] + assert "0/FT0" in names + assert "0/FT0-FT IDPROM" not in names + assert "FanBlade-0" in names + + def test_no_rules_includes_all(self): + """With empty rules list, no items are filtered (regression guard).""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Optics0/0/0/0", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalName": "Optics0/0/0/0-IDPROM", + "entPhysicalModelName": "DP04QSDD-HE0", + "entPhysicalSerialNum": "SER001", + "entPhysicalContainedIn": 1, + }, + ] + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [], depth=1, results=results) + names = [item["entPhysicalName"] for _, item in results] + assert "Optics0/0/0/0" in names + assert "Optics0/0/0/0-IDPROM" in names + + def test_transparent_item_children_promoted_to_same_depth(self): + """Children of a transparent-matched item are promoted to the transparent item's depth.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Module-Chassis-IDPROM", + "entPhysicalModelName": "CHASSIS-TYPE", + "entPhysicalSerialNum": "SER_CHASSIS", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalName": "Child-Module", + "entPhysicalModelName": "SFP-X2", + "entPhysicalSerialNum": "SER_SFP", + "entPhysicalContainedIn": 1, + }, + ] + rule = self._rule(match_type="ends_with", pattern="IDPROM", require_serial=False, action="transparent") + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [rule], depth=1, results=results) + + names = [item["entPhysicalName"] for _, item in results] + depths = [d for d, _ in results] + # Transparent item itself must not appear + assert "Module-Chassis-IDPROM" not in names + # Its child must be promoted to the same depth (1) as the transparent item would occupy + assert "Child-Module" in names + assert depths[names.index("Child-Module")] == 1 + + def test_transparent_item_without_children_produces_no_rows(self): + """A transparent item with no children yields nothing.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalName": "Leaf-IDPROM", + "entPhysicalModelName": "LEAF-MODEL", + "entPhysicalSerialNum": "LEAF_SER", + "entPhysicalContainedIn": 0, + }, + ] + rule = self._rule(match_type="ends_with", pattern="IDPROM", require_serial=False, action="transparent") + children_by_parent, index_map = self._build_maps(inventory) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, index_map, [rule], depth=1, results=results) + assert results == [] + + +class TestPositionalMatchClassAware: + """ + Positional fallback only tries bay-name patterns appropriate for the item's + hardware class. Without this, items like fans and PSUs land in chassis + line-card "Slot N" bays just because the slot number happens to align. + """ + + @staticmethod + def _walk(item_class, slot_num, bays): + """Drive _match_bay_by_position via a minimal inventory: chassis -> container -> item.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalModelName": "REAL-CHASSIS", + "entPhysicalClass": "chassis", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + }, + ] + # Add slot_num sibling containers under chassis so positional finds slot=slot_num + for n in range(1, slot_num + 1): + inventory.append( + { + "entPhysicalIndex": 100 + n, + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": n, + } + ) + item = { + "entPhysicalIndex": 999, + "entPhysicalModelName": "X", + "entPhysicalClass": item_class, + "entPhysicalContainedIn": 100 + slot_num, + "entPhysicalParentRelPos": 0, + } + inventory.append(item) + index_map = {i["entPhysicalIndex"]: i for i in inventory} + return BaseModuleTableView._match_bay_by_position(item, index_map, bays) + + @staticmethod + def _bay(name, position=None): + b = MagicMock() + b.name = name + b.position = position + return b + + @staticmethod + def _walk_port_label_fallback(item_name, slot_num, bays, ifname=None, ifdescr=None): + """Build a topology where positional slot differs from interface label index.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalModelName": "REAL-CHASSIS", + "entPhysicalClass": "chassis", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + }, + ] + for n in range(1, slot_num + 1): + inventory.append( + { + "entPhysicalIndex": 100 + n, + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": n, + } + ) + + item = { + "entPhysicalIndex": 999, + "entPhysicalModelName": "SFP-10G-SR", + "entPhysicalClass": "port", + "entPhysicalName": item_name, + "entPhysicalDescr": "", + "entPhysicalContainedIn": 100 + slot_num, + "entPhysicalParentRelPos": 0, + "_librenms_ifname": ifname, + "_librenms_ifdescr": ifdescr, + } + inventory.append(item) + index_map = {i["entPhysicalIndex"]: i for i in inventory} + return BaseModuleTableView._match_bay_by_position(item, index_map, bays) + + def test_fan_does_not_match_slot_bay(self): + """A fan (class=fan) must not land in a 'Slot 1' bay even when positional says slot 1.""" + bays = {"Slot 1": self._bay("Slot 1"), "Slot 2": self._bay("Slot 2")} + result = self._walk("fan", 1, bays) + assert result is None, ( + "Fan was matched to a chassis 'Slot N' bay. Positional patterns must be " + "class-aware: fans only match Fan / Fan Tray / FT N bays." + ) + + def test_fan_matches_fan_tray_bay(self): + """A fan matches a 'Fan Tray N' or 'Fan N' bay.""" + bays = {"Fan Tray 1": self._bay("Fan Tray 1"), "Slot 1": self._bay("Slot 1")} + result = self._walk("fan", 1, bays) + assert result is bays["Fan Tray 1"] + + def test_powersupply_does_not_match_slot_bay(self): + """A power supply must not match a 'Slot N' bay.""" + bays = {"Slot 2": self._bay("Slot 2"), "Slot 3": self._bay("Slot 3")} + result = self._walk("powerSupply", 2, bays) + assert result is None + + def test_powersupply_matches_psu_bay(self): + """A PSU matches Power Supply / PSU / PEM patterns.""" + bays = {"PSU 1": self._bay("PSU 1"), "Slot 1": self._bay("Slot 1")} + result = self._walk("powerSupply", 1, bays) + assert result is bays["PSU 1"] + + def test_module_still_matches_slot_bay(self): + """A module continues to match Slot/SFP/Bay/Port patterns.""" + bays = {"Slot 1": self._bay("Slot 1")} + result = self._walk("module", 1, bays) + assert result is bays["Slot 1"] + + def test_port_label_infers_slot_from_ifname_suffix(self): + """Port rows should infer bay index from ifName when positional slot does not match.""" + bays = {"SFP 1": self._bay("SFP 1")} + result = self._walk_port_label_fallback("Te1/1/1", 5, bays) + assert result is bays["SFP 1"] + + def test_port_label_infers_slot_from_ifdescr_suffix(self): + """Long-form labels (ifDescr) should infer the same slot index as short ifName labels.""" + bays = {"SFP 1": self._bay("SFP 1")} + result = self._walk_port_label_fallback( + "Port-Unknown", + 4, + bays, + ifname="", + ifdescr="TenGigabitEthernet1/1/1", + ) + assert result is bays["SFP 1"] + + def test_extract_interface_numeric_coordinates_preserves_existing_suffix_behavior(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + result = BaseModuleTableView._extract_interface_numeric_coordinates("xe-2/1/0 ") + assert result == [2, 1, 0] + + def test_extract_port_index_from_label_preserves_existing_suffix_behavior(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + assert BaseModuleTableView._extract_port_index_from_label("Eth42 ") == 42 + + def test_port_matches_typoed_bay_name_via_numeric_position(self): + """Numeric bay positions should rescue matching when the bay name is misspelled.""" + bays = { + "SFP 1": self._bay("SFP 1", position="1"), + "SFP2": self._bay("SFP2", position="2"), + } + result = self._walk("port", 2, bays) + assert result is bays["SFP2"] + + def test_port_matches_typoed_bay_name_via_alpha_position(self): + """Alphabetic bay positions should map sibling order 1->A, 2->B, etc.""" + bays = { + "SFP 1": self._bay("SFP 1", position="A"), + "SFP2": self._bay("SFP2", position="B"), + } + result = self._walk("port", 2, bays) + assert result is bays["SFP2"] + + def test_unknown_class_returns_none(self): + """An item with an unknown / empty class doesn't get a positional guess.""" + bays = {"Slot 1": self._bay("Slot 1")} + result = self._walk("sensor", 1, bays) + assert result is None + + +class TestNoBayWarningHints: + """`_build_no_bay_warning` distinguishes the common 'No Bay' causes.""" + + def test_empty_scope_mentions_missing_templates(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "module", "entPhysicalModelName": "X"} + msg = BaseModuleTableView._build_no_bay_warning(item, {}) + assert "no bay templates defined" in msg.lower() + + def test_scope_uninstalled_recommends_install_parent(self): + """Empty scope due to an uninstalled ancestor -> hint to install parent first.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "module", "entPhysicalModelName": "X"} + msg = BaseModuleTableView._build_no_bay_warning(item, {}, scope_uninstalled=True) + assert "install the parent module first" in msg.lower() + + def test_suggestion_appended_when_provided(self): + """`_build_no_bay_warning` includes the suggested mapping when one is provided.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "module"} + suggestion = { + "librenms_name": r"^0/(\d+)$", + "librenms_class": "module", + "netbox_bay_name": r"Slot \1", + "is_regex": True, + "example_item": "0/0", + "example_bay": "Slot 0", + } + msg = BaseModuleTableView._build_no_bay_warning(item, {"Slot 0": MagicMock()}, suggestion) + assert "0/(\\d+)" in msg + assert "Slot \\1" in msg + assert "0/0" in msg and "Slot 0" in msg + + def test_fan_class_hint_names_fan_bays(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "fan"} + msg = BaseModuleTableView._build_no_bay_warning(item, {"Slot 1": MagicMock()}) + assert "Fan" in msg + + def test_powersupply_class_hint_names_psu_bays(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "powerSupply"} + msg = BaseModuleTableView._build_no_bay_warning(item, {"Slot 1": MagicMock()}) + assert "PSU" in msg or "Power Supply" in msg or "PEM" in msg + + def test_module_class_hint_names_slot_bays(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "module"} + msg = BaseModuleTableView._build_no_bay_warning(item, {"Slot 1": MagicMock()}) + assert "Slot" in msg or "SFP" in msg + + def test_port_class_hint_uses_plain_language(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalClass": "port"} + msg = BaseModuleTableView._build_no_bay_warning(item, {"Slot 1": MagicMock()}) + assert "no matching bay in netbox" in msg.lower() + assert msg.lower().count("modulebaymapping") == 1 + assert "if the names differ" in msg.lower() + + +class TestSuggestBayMapping: + """`_suggest_bay_mapping` produces a regex mapping when a trailing-number bay is in scope.""" + + def test_suggests_regex_when_trailing_number_matches_bay(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/0", "entPhysicalClass": "module"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}) + assert sug is not None + assert sug["is_regex"] is True + assert sug["librenms_name"] == r"^0/(\d+)$" + assert sug["netbox_bay_name"] == r"Slot \1" + assert sug["librenms_class"] == "module" + assert sug["example_item"] == "0/0" + assert sug["example_bay"] == "Slot 0" + + def test_no_suggestion_when_no_bay_with_matching_trailing_number(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/0", "entPhysicalClass": "module"} + bay = MagicMock() + bay.name = "Slot 7" # trailing 7, not 0 + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 7": bay}) + assert sug is None + + def test_no_suggestion_when_item_has_no_trailing_number(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "Mainboard", "entPhysicalClass": "module"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}) + assert sug is None + + def test_no_suggestion_when_module_bays_empty(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/0", "entPhysicalClass": "module"} + sug = BaseModuleTableView._suggest_bay_mapping(item, {}) + assert sug is None + + def test_no_suggestion_when_scope_preserved(self): + """Suppress suggestions for sub-items whose scope was inherited from + an unmatched ancestor β€” the bays in scope are at the wrong level.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "TenGigE0/0/0/0", "entPhysicalClass": "module"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}, scope_preserved=True) + assert sug is None + + def test_no_suggestion_for_fan_when_only_slot_bays_exist(self): + """A fan must not be suggested into a chassis line-card slot bay.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/FT0", "entPhysicalClass": "fan"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}) + assert sug is None + + def test_suggestion_for_fan_when_fan_named_bay_exists(self): + """A fan with a fan-named bay in scope yields a fan-targeted suggestion.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/FT0", "entPhysicalClass": "fan"} + bay = MagicMock() + bay.name = "Fan Tray 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Fan Tray 0": bay}) + assert sug is not None + assert "Fan Tray" in sug["netbox_bay_name"] + + def test_no_suggestion_for_powersupply_when_only_slot_bays_exist(self): + """A power supply must not be suggested into a chassis line-card slot bay.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "0/PT0-PM0", "entPhysicalClass": "powerSupply"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}) + assert sug is None + + def test_suggests_letter_trail_for_carrier_child_bays(self): + """`Slot A` should map to `CPM A` via a letter-capturing regex even + when prefix tokens differ (`Slot` vs `CPM`). This is the common + follow-up after the user installs a controller-card carrier whose + empty child bays are letter-named.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "Slot A", "entPhysicalClass": "cpmModule"} + bay = MagicMock() + bay.name = "CPM A" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"CPM A": bay}) + assert sug is not None + assert sug["is_regex"] is True + assert sug["librenms_name"] == r"^Slot\ ([A-Za-z]+)$" + assert sug["netbox_bay_name"] == r"CPM \1" + assert sug["librenms_class"] == "cpmModule" + assert sug["example_item"] == "Slot A" + assert sug["example_bay"] == "CPM A" + + def test_no_letter_trail_suggestion_when_no_letter_bay(self): + """`Slot A` should NOT match `Slot 0` β€” bay trail must be of same kind.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalName": "Slot A", "entPhysicalClass": "cpmModule"} + bay = MagicMock() + bay.name = "Slot 0" + sug = BaseModuleTableView._suggest_bay_mapping(item, {"Slot 0": bay}) + assert sug is None + + +class TestSuggestBayMappingFromDescr: + """`_suggest_bay_mapping` falls back to a description-based regex when the + LibreNMS name is just a model number with no positional info β€” e.g. Juniper + 'JNP304-LMIC16-BASE' with description 'MIC: ... @ 0/0/*' should suggest a + mapping that targets the existing 'MIC 0' bay.""" + + def test_juniper_mic_descr_yields_mapping(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "JNP304-LMIC16-BASE", + "entPhysicalDescr": "MIC: MRATE LMIC 16x100G/4x400G @ 0/0/*", + "entPhysicalClass": "container", + } + bays = {"MIC 0": MagicMock(), "RE 0": MagicMock(), "RE 1": MagicMock()} + sug = BaseModuleTableView._suggest_bay_mapping(item, bays) + assert sug is not None + assert sug["is_regex"] is True + assert sug["netbox_bay_name"] == "MIC \\1" + assert sug["example_bay"] == "MIC 0" + # The pattern must fullmatch the original description (so the saved + # mapping actually resolves at lookup time). + import re as _re + + m = _re.fullmatch(sug["librenms_name"], item["entPhysicalDescr"]) + assert m is not None + assert m.expand(sug["netbox_bay_name"]) == "MIC 0" + + def test_no_descr_match_returns_none_for_container(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "X", + "entPhysicalDescr": "no class hint here", + "entPhysicalClass": "container", + } + sug = BaseModuleTableView._suggest_bay_mapping(item, {"MIC 0": MagicMock()}) + assert sug is None + + def test_descr_class_with_no_matching_bay_returns_none(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "X", + "entPhysicalDescr": "FPC: line card @ 5/0/*", + "entPhysicalClass": "container", + } + # Device only has MIC 0 β€” no FPC 5 bay β†’ no suggestion + sug = BaseModuleTableView._suggest_bay_mapping(item, {"MIC 0": MagicMock()}) + assert sug is None + + def test_descr_fallback_preferred_over_none_for_module_class(self): + """When name-based heuristic finds no candidate AND the item is a + normal module class (not container), descr fallback should still fire + β€” useful for vendor inventories that put the model in entPhysicalName + but classify the row as 'module'.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "JNP304-LMIC16-BASE", + "entPhysicalDescr": "MIC: MRATE LMIC 16x100G/4x400G @ 1/0/*", + "entPhysicalClass": "module", + } + bays = {"MIC 0": MagicMock(), "MIC 1": MagicMock()} + sug = BaseModuleTableView._suggest_bay_mapping(item, bays) + assert sug is not None + assert sug["example_bay"] == "MIC 1" + + def test_descr_trail_fallback_for_juniper_fan_tray_controller(self): + """Juniper fan trays carry the model in entPhysicalName ('JNP10008-FTC2') + and the human-readable position in entPhysicalDescr ('Fan Tray Controller 0'). + The class+slot descr regex doesn't match, but the trailing-number heuristic + on the description should still surface a usable mapping suggestion that + targets the existing 'Fan Tray 0' bay (mapping evaluation already considers + entPhysicalDescr, so this resolves at lookup time).""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "JNP10008-FTC2", + "entPhysicalDescr": "Fan Tray Controller 0", + "entPhysicalClass": "fan", + "entPhysicalModelName": "JNP10008-FTC2", + } + bays = {"Fan Tray 0": MagicMock(), "Fan Tray 1": MagicMock(), "FPC 0": MagicMock()} + sug = BaseModuleTableView._suggest_bay_mapping(item, bays) + assert sug is not None + assert sug["is_regex"] is True + assert sug["librenms_class"] == "fan" + assert sug["netbox_bay_name"] == "Fan Tray \\1" + assert sug["example_bay"] == "Fan Tray 0" + assert sug["example_item"] == "Fan Tray Controller 0" + # The pattern must fullmatch the descr so the saved mapping resolves at lookup time. + import re as _re + + m = _re.fullmatch(sug["librenms_name"], item["entPhysicalDescr"]) + assert m is not None + assert m.expand(sug["netbox_bay_name"]) == "Fan Tray 0" + + def test_descr_trail_fallback_skipped_when_descr_equals_name(self): + """If entPhysicalName already carries positional info and the + name-based heuristic still failed (e.g. no bay shares the trail), + the descr-trail fallback should not produce a suggestion when descr + is identical to name β€” the name-based pass was authoritative.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "Fan Tray 9", + "entPhysicalDescr": "Fan Tray 9", + "entPhysicalClass": "fan", + } + bays = {"Fan Tray 0": MagicMock(), "Fan Tray 1": MagicMock()} + sug = BaseModuleTableView._suggest_bay_mapping(item, bays) + assert sug is None + + def test_descr_trail_fallback_respects_class_filter(self): + """The descr-trail fallback receives the already-class-filtered candidate + list, so a fan whose descr ends in '0' must not be mapped onto a 'Slot 0' + line-card bay.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = { + "entPhysicalName": "JNP10008-FTC2", + "entPhysicalDescr": "Fan Tray Controller 0", + "entPhysicalClass": "fan", + } + bays = {"Slot 0": MagicMock(), "Slot 1": MagicMock()} # no fan-named bays + sug = BaseModuleTableView._suggest_bay_mapping(item, bays) + assert sug is None + + +class TestSuggestTypeMapping: + """`_suggest_type_mapping` produces a prefill dict for the ModuleTypeMapping form.""" + + def test_returns_none_when_model_blank(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + assert BaseModuleTableView._suggest_type_mapping({"entPhysicalModelName": ""}, None) is None + assert BaseModuleTableView._suggest_type_mapping({}, None) is None + + def test_returns_dict_with_librenms_model(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalModelName": "SFP-10G-SR", "entPhysicalDescr": "10GBASE-SR"} + sug = BaseModuleTableView._suggest_type_mapping(item, None) + assert sug is not None + assert sug["librenms_model"] == "SFP-10G-SR" + + def test_description_includes_physical_descr(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalModelName": "SFP-10G-SR", "entPhysicalDescr": "10GBASE-SR SFP+"} + sug = BaseModuleTableView._suggest_type_mapping(item, None) + assert "10GBASE-SR SFP+" in sug["description"] + + def test_description_includes_bay_name_when_bay_available(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + bay = MagicMock() + bay.name = "SFP 1" + item = {"entPhysicalModelName": "SFP-10G-SR", "entPhysicalDescr": "10GBASE-SR"} + sug = BaseModuleTableView._suggest_type_mapping(item, bay) + assert "SFP 1" in sug["description"] + + def test_description_omits_bay_name_when_no_bay(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalModelName": "GLC-TE", "entPhysicalDescr": "1000BaseT"} + sug = BaseModuleTableView._suggest_type_mapping(item, None) + assert sug is not None + assert "bay" not in sug["description"].lower() or "fitted" not in sug["description"] + + def test_unspecified_model_produces_suggestion(self): + """'Unspecified' is a valid librenms_model β€” a mapping can still be created.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + bay = MagicMock() + bay.name = "SFP 2" + item = {"entPhysicalModelName": "Unspecified", "entPhysicalDescr": "1000BaseT"} + sug = BaseModuleTableView._suggest_type_mapping(item, bay) + assert sug is not None + assert sug["librenms_model"] == "Unspecified" + assert "SFP 2" in sug["description"] + + +class TestSuggestModuleTypeCreate: + """`_suggest_module_type_create` produces a prefill dict for NetBox's native ModuleType create form.""" + + def test_returns_none_when_model_blank(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + assert BaseModuleTableView._suggest_module_type_create({"entPhysicalModelName": ""}, None) is None + assert BaseModuleTableView._suggest_module_type_create({}, None) is None + + def test_prefills_model_and_part_number(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalModelName": "X2-10GB-LR", "entPhysicalDescr": "10Gbase-LR"} + sug = BaseModuleTableView._suggest_module_type_create(item, None) + assert sug["model"] == "X2-10GB-LR" + assert sug["part_number"] == "X2-10GB-LR" + assert sug["description"] == "10Gbase-LR" + assert "manufacturer" not in sug + assert "comments" not in sug + + def test_prefills_manufacturer_pk(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + manufacturer = MagicMock() + manufacturer.pk = 42 + item = {"entPhysicalModelName": "X2-10GB-LR"} + sug = BaseModuleTableView._suggest_module_type_create(item, manufacturer) + assert sug["manufacturer"] == 42 + + def test_truncates_long_model_to_100_and_part_number_to_50(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + long_model = "M" * 150 + item = {"entPhysicalModelName": long_model} + sug = BaseModuleTableView._suggest_module_type_create(item, None) + assert len(sug["model"]) == 100 + assert len(sug["part_number"]) == 50 + + def test_truncates_description_to_200_and_overflow_into_comments(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + long_desc = "D" * 250 + item = {"entPhysicalModelName": "M", "entPhysicalDescr": long_desc} + sug = BaseModuleTableView._suggest_module_type_create(item, None) + assert len(sug["description"]) == 200 + assert sug["comments"] == long_desc + + def test_short_description_does_not_set_comments(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + item = {"entPhysicalModelName": "M", "entPhysicalDescr": "short"} + sug = BaseModuleTableView._suggest_module_type_create(item, None) + assert sug["description"] == "short" + assert "comments" not in sug + + +class TestNoTypeWarningHints: + """`_build_no_type_warning` mentions the missing model name.""" + + def test_includes_model_name(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + msg = BaseModuleTableView._build_no_type_warning({"entPhysicalModelName": "ASR-9904-FAN"}) + assert "ASR-9904-FAN" in msg + assert "ModuleType" in msg + + def test_handles_missing_model_name(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + msg = BaseModuleTableView._build_no_type_warning({"entPhysicalModelName": ""}) + assert msg # non-empty string + + +class TestBuildRowModelWarning: + """`_build_row` populates `model_warning` for No Bay / No Type rows.""" + + def _view(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + v = object.__new__(BaseModuleTableView) + v._device_manufacturer = None + return v + + def test_no_bay_row_gets_model_warning(self): + view = self._view() + view._match_module_bay = MagicMock(return_value=None) + item = {"entPhysicalName": "0/FT0", "entPhysicalClass": "fan", "entPhysicalModelName": "ASR-FAN"} + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="ASR-FAN", pk=1)), + ): + row = view._build_row(item, {}, {"Slot 1": MagicMock()}, {"ASR-FAN": MagicMock(pk=1)}) + assert row["status"] == "No Bay" + assert "model_warning" in row + assert row["model_warning"], "expected non-empty hint" + + def test_no_type_row_gets_model_warning(self): + view = self._view() + bay = MagicMock() + bay.name = "Slot 1" + bay.installed_module = None + bay.get_absolute_url.return_value = "/b" + view._match_module_bay = MagicMock(return_value=bay) + item = {"entPhysicalName": "X", "entPhysicalClass": "module", "entPhysicalModelName": "UNKNOWN-MODEL"} + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=None), + ): + row = view._build_row(item, {}, {}, {}) + assert row["status"] == "No Type" + assert "UNKNOWN-MODEL" in row.get("model_warning", "") + + def test_no_type_row_carries_module_type_create_prefill(self): + """A No Type row exposes a `module_type_create` dict so the table can + render the "Add Module Type" button linking to NetBox's native form.""" + view = self._view() + bay = MagicMock() + bay.name = "Slot 1" + bay.installed_module = None + bay.get_absolute_url.return_value = "/b" + view._match_module_bay = MagicMock(return_value=bay) + manufacturer = MagicMock() + manufacturer.pk = 99 + item = { + "entPhysicalName": "X", + "entPhysicalClass": "module", + "entPhysicalModelName": "X2-10GB-LR", + "entPhysicalDescr": "10Gbase-LR", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=None), + ): + row = view._build_row(item, {}, {}, {}, manufacturer=manufacturer) + assert row["status"] == "No Type" + create = row.get("module_type_create") + assert create is not None + assert create["model"] == "X2-10GB-LR" + assert create["part_number"] == "X2-10GB-LR" + assert create["manufacturer"] == 99 + assert create["description"] == "10Gbase-LR" + + def test_matched_row_has_no_model_warning(self): + view = self._view() + bay = MagicMock() + bay.name = "Slot 1" + bay.installed_module = None + bay.get_absolute_url.return_value = "/b" + view._match_module_bay = MagicMock(return_value=bay) + mt = MagicMock(pk=10) + mt.model = "M" + mt.get_absolute_url.return_value = "/mt" + item = {"entPhysicalName": "X", "entPhysicalClass": "module", "entPhysicalModelName": "M"} + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=mt), + ): + row = view._build_row(item, {}, {"Slot 1": bay}, {"M": mt}) + assert row["status"] == "Matched" + assert "model_warning" not in row + + def test_no_bay_row_carries_model_suggestion_when_trailing_number_matches(self): + """A No Bay row whose item name shares a trailing number with a bay + in scope gets a `model_suggestion` field consumable by the table.""" + view = self._view() + view._match_module_bay = MagicMock(return_value=None) + bay = MagicMock() + bay.name = "Slot 0" + item = {"entPhysicalName": "0/0", "entPhysicalClass": "module", "entPhysicalModelName": "X"} + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="X", pk=1)), + ): + row = view._build_row(item, {}, {"Slot 0": bay}, {"X": MagicMock(pk=1)}) + assert row["status"] == "No Bay" + sug = row.get("model_suggestion") + assert sug is not None + assert sug["librenms_name"] == r"^0/(\d+)$" + assert sug["netbox_bay_name"] == r"Slot \1" + + def test_scope_uninstalled_no_bay_row_recommends_install_parent(self): + """When scope is empty due to an uninstalled ancestor, the warning + text instructs the user to install the parent module first.""" + view = self._view() + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalClass": "module", + "entPhysicalModelName": "SFP-X", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch( + "netbox_librenms_plugin.utils.resolve_module_type", + return_value=MagicMock(model="SFP-X", pk=1), + ), + ): + row = view._build_row(item, {}, {}, {"SFP-X": MagicMock(pk=1)}, scope_uninstalled=True) + assert row["status"] == "No Bay" + assert "install the parent module first" in row.get("model_warning", "").lower() + + def test_no_bay_empty_parent_bays_sets_no_bay_reason(self): + """When parent module is installed but has no bay templates, _build_row + tags the row with no_bay_reason='empty_parent_bays'.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalClass": "module", + "entPhysicalModelName": "SFP-10G-SR", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="SFP-10G-SR", pk=1)), + ): + # scope_empty_installed_bays=True: installed parent has no bay templates + row = view._build_row( + item, + {}, + {}, + {"SFP-10G-SR": MagicMock(pk=1)}, + scope_empty_installed_bays=True, + ) + assert row["status"] == "No Bay" + assert row.get("no_bay_reason") == "empty_parent_bays" + + def test_no_bay_empty_parent_bays_through_intermediate_container(self): + """Even with scope_preserved=True (intermediate unmatched container), + no_bay_reason is still set when scope_empty_installed_bays=True.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalClass": "module", + "entPhysicalModelName": "SFP-10G-SR", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="SFP-10G-SR", pk=1)), + ): + row = view._build_row( + item, + {}, + {}, + {"SFP-10G-SR": MagicMock(pk=1)}, + scope_preserved=True, + scope_empty_installed_bays=True, + ) + assert row["status"] == "No Bay" + assert row.get("no_bay_reason") == "empty_parent_bays" + + def test_no_bay_port_child_uses_interface_child_reason(self): + """Port-class child rows under empty installed-parent scope should not be tagged as missing bay templates.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "Ethernet1/1", + "entPhysicalClass": "port", + "entPhysicalModelName": "SFP-10G-SR", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="SFP-10G-SR", pk=1)), + ): + row = view._build_row( + item, + {}, + {}, + {"SFP-10G-SR": MagicMock(pk=1)}, + scope_empty_installed_bays=True, + ) + assert row["status"] == "No Bay" + assert row.get("no_bay_reason") == "interface_child" + assert "matching child bay is missing in netbox" in row.get("model_warning", "").lower() + + def test_port_row_sets_can_install_and_interface_hint_when_bay_matches(self): + """Matched port rows expose install action and preserve best interface label hint.""" + view = self._view() + bay = MagicMock() + bay.name = "SFP 1" + bay.installed_module = None + bay.pk = 10 + bay.get_absolute_url.return_value = "/dcim/module-bays/10/" + view._match_module_bay = MagicMock(return_value=bay) + + module_type = MagicMock() + module_type.model = "SFP-10G-SR" + module_type.pk = 200 + module_type.get_absolute_url.return_value = "/dcim/module-types/200/" + + item = { + "entPhysicalName": "Port-Unknown", + "entPhysicalClass": "port", + "entPhysicalModelName": "SFP-10G-SR", + "_librenms_ifname": "Te1/1/1", + "_librenms_ifdescr": "TenGigabitEthernet1/1/1", + "_librenms_port_id": 1234, + } + + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=module_type), + ): + row = view._build_row(item, {}, {"SFP 1": bay}, {"SFP-10G-SR": module_type}) + + assert row["can_install"] is True + assert row["interface_name_hint"] == "Te1/1/1" + assert row["librenms_port_id"] == 1234 + assert row["librenms_ifname"] == "Te1/1/1" + assert row["librenms_ifdescr"] == "TenGigabitEthernet1/1/1" + + def test_no_bay_default_scope_empty_flag_does_not_set_reason(self): + """Without scope_empty_installed_bays, plain empty scope gives no reason tag.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalClass": "module", + "entPhysicalModelName": "SFP-10G-SR", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="SFP-10G-SR", pk=1)), + ): + # Default scope_empty_installed_bays=False β€” could be unmatched ancestor + row = view._build_row(item, {}, {}, {"SFP-10G-SR": MagicMock(pk=1)}) + assert row["status"] == "No Bay" + assert "no_bay_reason" not in row + + def test_no_bay_with_bays_in_scope_does_not_set_no_bay_reason(self): + """When module_bays is non-empty (just no match), no_bay_reason is absent.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + bay = MagicMock() + bay.name = "Slot 1" + item = { + "entPhysicalName": "0/5", + "entPhysicalClass": "module", + "entPhysicalModelName": "X", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="X", pk=1)), + ): + row = view._build_row(item, {}, {"Slot 1": bay}, {"X": MagicMock(pk=1)}) + assert row["status"] == "No Bay" + assert "no_bay_reason" not in row + + def test_no_bay_scope_uninstalled_does_not_set_no_bay_reason(self): + """scope_uninstalled=True is a different root cause; no_bay_reason absent.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._match_module_bay = MagicMock(return_value=None) + item = { + "entPhysicalName": "TenGigE0/0/0/0", + "entPhysicalClass": "module", + "entPhysicalModelName": "SFP-X", + } + with ( + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=MagicMock(model="SFP-X", pk=1)), + ): + row = view._build_row(item, {}, {}, {"SFP-X": MagicMock(pk=1)}, scope_uninstalled=True) + assert row["status"] == "No Bay" + assert "no_bay_reason" not in row + + +class TestModelIncompleteFlag: + """_append_rows_for_item_context sets model_incomplete on parent when + installed module has no bay templates and children show no_bay_reason.""" + + def _make_parent_row(self, **kwargs): + row = { + "librenms_name": "0/0", + "status": "Installed", + "module_bay": "Slot 0", + "module_bay_id": 1, + } + row.update(kwargs) + return row + + def test_model_incomplete_set_when_child_has_no_bay_reason(self): + """Parent is flagged model_incomplete when child rows have no_bay_reason='empty_parent_bays'.""" + parent_row = self._make_parent_row() + child_row = { + "librenms_name": "TenGigE0/0/0/0", + "status": "No Bay", + "no_bay_reason": "empty_parent_bays", + } + table_data = [parent_row, child_row] + parent_row_idx = 0 + + mt = MagicMock() + mt.get_absolute_url.return_value = "/dcim/module-types/5/" + mt.__str__ = lambda self: "A9K-24X10GE-1G-TR" + installed_module = MagicMock() + installed_module.module_type = mt + + # Simulate the flagging logic from _append_rows_for_item_context + child_bays = {} + if installed_module and not child_bays: + has_no_bay_children = any( + table_data[i].get("no_bay_reason") == "empty_parent_bays" + for i in range(parent_row_idx + 1, len(table_data)) + ) + if has_no_bay_children: + mt_ = installed_module.module_type + table_data[parent_row_idx]["model_incomplete"] = True + table_data[parent_row_idx]["model_incomplete_url"] = mt_.get_absolute_url() + table_data[parent_row_idx]["model_incomplete_name"] = str(mt_) + + assert table_data[0].get("model_incomplete") is True + assert "/dcim/module-types/5/" in table_data[0].get("model_incomplete_url", "") + + def test_model_incomplete_not_set_when_no_children_with_no_bay_reason(self): + """If children don't have no_bay_reason, parent stays unflagged even if child_bays empty.""" + parent_row = self._make_parent_row() + child_row = { + "librenms_name": "TenGigE0/0/0/0", + "status": "Installed", + } + table_data = [parent_row, child_row] + parent_row_idx = 0 + + mt = MagicMock() + installed_module = MagicMock() + installed_module.module_type = mt + child_bays = {} + + if installed_module and not child_bays: + has_no_bay_children = any( + table_data[i].get("no_bay_reason") == "empty_parent_bays" + for i in range(parent_row_idx + 1, len(table_data)) + ) + if has_no_bay_children: + table_data[parent_row_idx]["model_incomplete"] = True + + assert "model_incomplete" not in table_data[0] + + def test_model_incomplete_not_set_when_child_bays_nonempty(self): + """If the installed module DOES have bays in scope, no model_incomplete flag.""" + parent_row = self._make_parent_row() + child_row = { + "librenms_name": "TenGigE0/0/0/0", + "status": "No Bay", + "no_bay_reason": "empty_parent_bays", + } + table_data = [parent_row, child_row] + parent_row_idx = 0 + + mt = MagicMock() + installed_module = MagicMock() + installed_module.module_type = mt + child_bays = {"Bay 0": MagicMock()} # non-empty + + if installed_module and not child_bays: + has_no_bay_children = any( + table_data[i].get("no_bay_reason") == "empty_parent_bays" + for i in range(parent_row_idx + 1, len(table_data)) + ) + if has_no_bay_children: + table_data[parent_row_idx]["model_incomplete"] = True + + assert "model_incomplete" not in table_data[0] + + +class TestRenderStatusNoBayOnParent: + """render_status correctly labels child rows and parent 'Fix Model' badge.""" + + def _table(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + return object.__new__(LibreNMSModuleTable) + + def test_no_bay_on_parent_label_for_empty_parent_bays(self): + """Status cell shows 'No Bay on Parent' when no_bay_reason == 'empty_parent_bays'.""" + table = self._table() + record = {"status": "No Bay", "no_bay_reason": "empty_parent_bays"} + html = table.render_status("No Bay", record) + assert "No Bay on Parent" in str(html) + assert "No Bay" in str(html) # badge text changed but still present as substring + + def test_interface_child_label_for_interface_descendants(self): + """Status cell shows 'Missing Child Bay' for interface-like no-bay descendants.""" + table = self._table() + record = {"status": "No Bay", "no_bay_reason": "interface_child"} + html = table.render_status("No Bay", record) + assert "Missing Child Bay" in str(html) + + def test_plain_no_bay_label_without_reason(self): + """Without no_bay_reason, status cell shows plain 'No Bay'.""" + table = self._table() + record = {"status": "No Bay"} + html = table.render_status("No Bay", record) + assert "No Bay on Parent" not in str(html) + assert "No Bay" in str(html) + + def test_fix_model_badge_with_url(self): + """Parent row with model_incomplete + model_incomplete_url renders a link badge.""" + table = self._table() + record = { + "status": "Installed", + "model_incomplete": True, + "model_incomplete_url": "/dcim/module-types/5/", + "model_incomplete_name": "A9K-24X10GE-1G-TR", + } + html = str(table.render_status("Installed", record)) + assert "Fix Model" in html + assert "/dcim/module-types/5/" in html + assert "A9K-24X10GE-1G-TR" in html + + def test_fix_model_badge_without_url_is_span(self): + """When model_incomplete_url is absent, badge is rendered as .""" + table = self._table() + record = { + "status": "Installed", + "model_incomplete": True, + "model_incomplete_name": "SomeType", + } + html = str(table.render_status("Installed", record)) + assert "Fix Model" in html + assert "