diff --git a/.devcontainer/config/codespaces-configuration.py b/.devcontainer/config/codespaces-configuration.py index 84c1b669cb..3ba387bf0b 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 b64357a8fe..a2be7efa2e 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 405d9e0380..405dd0a9fd 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 4f02754b5b..7d6795cb65 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 0000000000..1d0d29b6d9 --- /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 16b6e8858d..beb3ffc7bc 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/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000000..bbd8413039 --- /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 0000000000..f92b6471d0 --- /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 0000000000..2dec241524 --- /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 0000000000..3f48cd94b9 --- /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 0000000000..e56b7c997b --- /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 0000000000..27c5a86df0 --- /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 0000000000..c0346aba0e --- /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 0000000000..6938fad17a --- /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 0000000000..f599a6150e --- /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 4acfead73f..216d76f8e1 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 diff --git a/docs/development/testing.md b/docs/development/testing.md index 1e1290a25f..d680b030a9 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 0abaad7b8b..1c4cb5ff48 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 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 0000000000..c30281afed 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 0000000000..40822c8c5c 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 0000000000..e76058a18b 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 0000000000..939b364d9b 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 0000000000..6daa368904 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 0000000000..1d959be036 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 0000000000..ac611cc84a 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 0000000000..a2a98698ae 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 0000000000..0fb061a42c 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 0000000000..48167aa405 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 0000000000..1e9ae0a80c 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 0000000000..bb8140559c 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 0000000000..21d59aa183 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 efd08257f3..4dd9158aad 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 43527cf3f4..dde4d70062 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 2571d1f143..0295a1e052 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 0000000000..2079089f3d --- /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 0000000000..7205a8e20c --- /dev/null +++ b/docs/usage_tips/module_sync.md @@ -0,0 +1,56 @@ +# 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 + +## 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, a **Suggest 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/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef20..fe576d712a 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 230aa078d0..de5f82b4aa 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 a5d440b8fb..a73fb8069f 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/constants.py b/netbox_librenms_plugin/constants.py index 4e542f9d15..4b73a0fc4d 100644 --- a/netbox_librenms_plugin/constants.py +++ b/netbox_librenms_plugin/constants.py @@ -1,6 +1,30 @@ +import re + # Plugin permissions (from LibreNMSSettings model) PERM_VIEW_PLUGIN = "netbox_librenms_plugin.view_librenmssettings" PERM_CHANGE_PLUGIN = "netbox_librenms_plugin.change_librenmssettings" # LibreNMS VLAN state values LIBRENMS_VLAN_STATE_ACTIVE = 1 + +# OOB management controller detection +OOB_TYPE_PATTERN = re.compile(r"\b(idrac|ilo|ipmi|bmc|drac)", re.IGNORECASE) +OOB_TYPES = ("idrac", "ilo", "ipmi", "bmc", "drac") + + +def normalize_oob_type(os_str: str, hardware_str: str = "") -> str | None: + """ + Extract and normalize the OOB controller type from LibreNMS os/hardware strings. + + Returns the canonical lowercase token (one of OOB_TYPES) or None if no match. + + Examples: + normalize_oob_type("drac9", "iDRAC9") β†’ "drac" + normalize_oob_type("ilo", "") β†’ "ilo" + normalize_oob_type("ubuntu", "") β†’ None + """ + for text in (os_str or "", hardware_str or ""): + m = OOB_TYPE_PATTERN.search(text) + if m: + return m.group(1).lower() + return None diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64c..1edb57773c 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 da1417b715..e144da69e7 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") @@ -131,12 +162,24 @@ class ImportSettingsForm(NetBoxModelForm): help_text="Remove domain suffix from device names during import", ) + auto_create_ipam_default = forms.BooleanField( + label="Auto-create IPAM entries", + required=False, + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + help_text=( + "When enabled, missing IP addresses reported by LibreNMS are auto-created " + "as global /32 (IPv4) or /128 (IPv6) IPAM records during initial import, " + "OOB-attach, and promote-to-host actions. Existing IPAM records are always reused." + ), + ) + class Meta: model = LibreNMSSettings fields = [ "vc_member_name_pattern", "use_sysname_default", "strip_domain_default", + "auto_create_ipam_default", ] def clean_vc_member_name_pattern(self): @@ -153,8 +196,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 +302,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 +727,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 +773,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 +812,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 +825,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/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py index 81c24025ea..385c7ff402 100644 --- a/netbox_librenms_plugin/import_utils/__init__.py +++ b/netbox_librenms_plugin/import_utils/__init__.py @@ -33,6 +33,8 @@ import_single_device, validate_device_for_import, ) +from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip # noqa: F401 +from .collisions import detect_bulk_collisions # noqa: F401 from .filters import ( # noqa: F401 _apply_client_filters, get_device_count_for_filters, diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 66e741b1d6..7804acd77b 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -200,6 +200,7 @@ def bulk_import_devices_shared( "device_id": device_id, "device": result["device"], "message": result["message"], + "created_ips": result.get("created_ips", []), } ) # Log progress after each successful import @@ -228,7 +229,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 +358,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 +379,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 +455,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 b6860a3fea..3728e6a9ba 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/collisions.py b/netbox_librenms_plugin/import_utils/collisions.py new file mode 100644 index 0000000000..8de5505cd6 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/collisions.py @@ -0,0 +1,159 @@ +""" +Bulk-import collision detection. + +When a user selects multiple LibreNMS devices for bulk import, two or more +rows in the same batch may resolve to the *same* NetBox device β€” for +example, one row would be linked as the host and another as the OOB +controller, or two rows both want to promote to the same existing host. +Importing all of them blindly would race for the same custom-field slot +and produce inconsistent state. + +`detect_bulk_collisions` walks the per-row validation results and groups +rows that target the same NetBox device pk so the bulk-confirm view can +block the import and let the user adjust their selection. +""" + +from __future__ import annotations + + +def _candidate_pks_for_row(validation: dict) -> list[tuple[int, str, str]]: + """Return [(nb_device_pk, nb_device_name, role)] candidates for a single row. + + ``role`` is a short human-readable label describing how this LibreNMS + row would touch the NetBox device: + + * ``"host"`` β€” the device is the existing NetBox device this row would + update / link to (``existing_device``). + * ``"oob"`` β€” this LibreNMS row would be installed as the OOB + controller of the NetBox device (``oob_candidate.device``). + * ``"merge_host_named"`` / ``"merge_oob_named"`` β€” the row would feed + a Stage-2 merge of two NetBox devices. + * ``"promote_target"`` β€” the row should be promoted to host of an + existing NetBox device that currently only has an OOB link + (``promote_to_host``). + + Duplicate ``(pk, role)`` tuples are de-duplicated; a single row may + legitimately surface the same pk under different roles. + """ + candidates: list[tuple[int, str, str]] = [] + seen: set[tuple[int, str]] = set() + + def _add(pk, name, role): + try: + pk_int = int(pk) + except (TypeError, ValueError): + return + key = (pk_int, role) + if key in seen: + return + seen.add(key) + candidates.append((pk_int, str(name or f"device-{pk_int}"), role)) + + existing = validation.get("existing_device") + if existing is not None and getattr(existing, "pk", None) is not None: + _add(existing.pk, getattr(existing, "name", None), "host") + + oob_candidate = validation.get("oob_candidate") or {} + oob_device = oob_candidate.get("device") if isinstance(oob_candidate, dict) else None + if oob_device is not None and getattr(oob_device, "pk", None) is not None: + _add(oob_device.pk, getattr(oob_device, "name", None), "oob") + + merge = validation.get("merge_candidates") or {} + if isinstance(merge, dict): + for slot, role in (("host_named", "merge_host_named"), ("oob_named", "merge_oob_named")): + entry = merge.get(slot) or {} + pk = entry.get("pk") if isinstance(entry, dict) else None + name = entry.get("name") if isinstance(entry, dict) else None + if pk is not None: + _add(pk, name, role) + + promote = validation.get("promote_to_host") or {} + if isinstance(promote, dict): + target = promote.get("existing_device") + if target is not None and getattr(target, "pk", None) is not None: + _add(target.pk, getattr(target, "name", None), "promote_target") + target_pk = promote.get("existing_device_pk") + if target_pk is not None: + _add(target_pk, promote.get("existing_device_name"), "promote_target") + + return candidates + + +def detect_bulk_collisions(devices: list[dict]) -> list[dict]: + """Find groups of LibreNMS rows in *devices* that resolve to the same NetBox device. + + *devices* matches the list assembled by ``BulkImportConfirmView`` β€” + each item is a dict with at least ``device_id``, ``device_name`` and + ``validation`` keys. + + Returns a list of collision groups (one per offending NetBox pk), + sorted by ``nb_device_pk`` for stable rendering. Each group: + + .. code-block:: python + + { + "nb_device_pk": int, + "nb_device_name": str, + "librenms_rows": [ + {"device_id": int, "hostname": str, "role": str}, + ... + ], + } + + Rows are de-duplicated by ``device_id`` within a group (same LibreNMS + row touching the same NetBox device under multiple roles only appears + once, with all matching role labels joined by ``", "``). + + A group is only emitted when at least two distinct LibreNMS + ``device_id`` values target the same NetBox pk. + """ + by_nb_pk: dict[int, dict] = {} + + for entry in devices or []: + validation = entry.get("validation") or {} + try: + libre_id = int(entry.get("device_id")) + except (TypeError, ValueError): + continue + hostname = entry.get("device_name") or f"device-{libre_id}" + + for nb_pk, nb_name, role in _candidate_pks_for_row(validation): + bucket = by_nb_pk.setdefault( + nb_pk, + {"nb_device_pk": nb_pk, "nb_device_name": nb_name, "_rows": {}}, + ) + # Keep the first non-default name we see β€” rows often disagree + # on the cached display string, but the underlying pk is the + # source of truth. + if bucket["nb_device_name"].startswith("device-") and not nb_name.startswith("device-"): + bucket["nb_device_name"] = nb_name + + row = bucket["_rows"].setdefault( + libre_id, + {"device_id": libre_id, "hostname": hostname, "roles": []}, + ) + if role not in row["roles"]: + row["roles"].append(role) + + collisions: list[dict] = [] + for nb_pk in sorted(by_nb_pk.keys()): + bucket = by_nb_pk[nb_pk] + rows = list(bucket["_rows"].values()) + if len(rows) < 2: + continue + rows.sort(key=lambda r: r["device_id"]) + collisions.append( + { + "nb_device_pk": bucket["nb_device_pk"], + "nb_device_name": bucket["nb_device_name"], + "librenms_rows": [ + { + "device_id": r["device_id"], + "hostname": r["hostname"], + "role": ", ".join(r["roles"]), + } + for r in rows + ], + } + ) + return collisions diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 152414998b..9b17b9841a 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -12,12 +12,15 @@ from ..librenms_api import LibreNMSAPI from ..utils import ( + coerce_librenms_id, find_by_librenms_id, find_matching_platform, find_matching_site, + get_librenms_oob, match_librenms_hardware_to_device_type, set_librenms_device_id, ) +from ..constants import OOB_TYPE_PATTERN, normalize_oob_type from .cache import get_import_device_cache_key from .virtual_chassis import ( _generate_vc_member_name, @@ -29,6 +32,51 @@ logger = logging.getLogger(__name__) +def _detect_oob_type_from_name(name): + """Return canonical OOB type token (idrac/ilo/ipmi/bmc/drac) found in *name*, or None.""" + if not name: + return None + m = OOB_TYPE_PATTERN.search(name) + return m.group(1).lower() if m else None + + +def _describe_existing_librenms_link(obj, server_key): + """ + Describe the current LibreNMS linkage on a NetBox object. + + Returns a dict ``{"host_id": int|None, "oob_id": int|None, "oob_type": str|None}`` + summarising the ``librenms_id`` custom field for *server_key*. Always returns a + dict (with all-None values if nothing is linked) so callers can treat it as a + plain status object. Tolerates legacy bare-int and dict-form custom field values. + """ + info = {"host_id": None, "oob_id": None, "oob_type": None} + cf_value = obj.cf.get("librenms_id") if hasattr(obj, "cf") else None + # Legacy bare-int OR string-digit (pre-JSON format). + if not isinstance(cf_value, dict): + info["host_id"] = coerce_librenms_id(cf_value) + return info + entry = cf_value.get(server_key) + # Per-server simple form: legacy bare-int or string-digit under the server key. + if not isinstance(entry, dict): + info["host_id"] = coerce_librenms_id(entry) + return info + # New dict-form: {"id": , "oob": {"id": , "type": , ...}}. + # Inner ids are always written as ints by set_librenms_device_id, so a strict + # int check is sufficient here β€” no string-digit fallback needed. + host_id = entry.get("id") + if isinstance(host_id, int) and not isinstance(host_id, bool): + info["host_id"] = host_id + oob = entry.get("oob") + if isinstance(oob, dict): + oob_id = oob.get("id") + if isinstance(oob_id, int) and not isinstance(oob_id, bool): + info["oob_id"] = oob_id + oob_type = oob.get("type") + if isinstance(oob_type, str) and oob_type: + info["oob_type"] = oob_type + return info + + def _try_chassis_device_type_match(api, device_id): """ Attempt device type matching using chassis inventory fields. @@ -206,10 +254,14 @@ def validate_device_for_import( "resolved_name": None, # Final device name after applying user preferences "existing_device": None, "existing_match_type": None, # Track how existing device was matched - "serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs" + "serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs", "oob_candidate", "promote_to_host", "merge_netbox_devices" "serial_confirmed": False, # True when librenms_id match and serial matches "serial_duplicate": False, # True when incoming serial is already on a different device "librenms_id_needs_migration": False, # True when existing device has legacy bare-int ID + "oob_candidate": None, # dict {device, type, version, ip} when oob_candidate detected + "promote_to_host": None, # dict {existing_libre_id, existing_oob_type} when incoming should become the host + "existing_librenms_link": None, # dict {host_id, oob_id, oob_type} describing existing device's current LibreNMS linkage + "merge_candidates": None, # dict {host_named: {pk,name,librenms_link}, oob_named: {pk,name,librenms_link}} when two NB devices look like the same physical box "name_matches": False, # True when existing device name matches LibreNMS sysName "name_sync_available": False, # True when existing device name differs from sysName "suggested_name": None, # sysName to suggest when name_sync_available is True @@ -325,6 +377,15 @@ def validate_device_for_import( result["existing_match_type"] = "librenms_id" result["can_import"] = False + # If the match was via the OOB sub-key, mark it so the UI shows no duplicate warning. + _existing_oob = get_librenms_oob(existing_device, server_key=server_key) + if _existing_oob and _existing_oob.get("id") == librenms_id: + result["existing_match_type"] = "librenms_oob" + + # Surface the full host/OOB linkage so the import table can render + # both halves of an existing pair with consistent paired styling. + result["existing_librenms_link"] = _describe_existing_librenms_link(existing_device, server_key) + # Detect legacy bare-integer or string-digit format so UI can offer a migration action. # Direct access needed to detect legacy format for migration prompt: # LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the @@ -450,18 +511,180 @@ def validate_device_for_import( result["existing_match_type"] = "serial" result["can_import"] = False - if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower(): - result["warnings"].append( - f"Device with same serial and hostname exists as '{existing_by_serial.name}' " - f"(not linked to LibreNMS)" + # Capture existing device's current LibreNMS linkage so the UI can + # present accurate state (NOT just "not linked to LibreNMS"). + existing_link = _describe_existing_librenms_link(existing_by_serial, server_key) + result["existing_librenms_link"] = existing_link + + # Compute both possible roles for the incoming LibreNMS device against + # the existing NetBox device, then pick a heuristic default. The UI + # offers a manual toggle whenever both roles are feasible so the user + # can override the heuristic (e.g. mark a "linux"-OS device as OOB or + # demote an apparent host into the OOB slot). + oob_type_from_libre = normalize_oob_type( + libre_device.get("os", ""), + libre_device.get("hardware", ""), + ) + existing_oob = get_librenms_oob(existing_by_serial, server_key=server_key) + + # Only treat this as a possible host/OOB chassis-pair situation when + # there is a real ambiguity: either the existing NetBox device's name + # differs from the incoming LibreNMS hostname (so they likely represent + # two sides of one physical box), or the existing device is already + # linked to a different LibreNMS id. When names match exactly and the + # existing has no link, the user almost certainly just wants to link. + names_match = bool( + existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower() + ) + already_linked_elsewhere = bool( + existing_link + and existing_link["host_id"] + and existing_link["host_id"] != libre_device.get("device_id") + ) + chassis_pair_likely = (not names_match) or already_linked_elsewhere + + oob_possible = chassis_pair_likely and existing_oob is None + host_possible = chassis_pair_likely and bool( + existing_link + and existing_link["host_id"] + and existing_link["host_id"] != libre_device.get("device_id") + and not existing_link.get("oob_id") + ) + existing_oob_from_name = _detect_oob_type_from_name(existing_by_serial.name) + + if oob_possible: + inferred_oob_type = ( + oob_type_from_libre + or _detect_oob_type_from_name( + libre_device.get("hostname") or libre_device.get("sysName") or "" + ) + or "oob" ) + result["oob_candidate"] = { + "device": existing_by_serial, + "type": inferred_oob_type, + "version": libre_device.get("version") or None, + "ip": libre_device.get("ip") or None, + } + if host_possible: + result["promote_to_host"] = { + "existing_libre_id": existing_link["host_id"], + "existing_oob_type": existing_oob_from_name or "oob", + } + + # Heuristic default: incoming-OS clearly OOB -> oob; otherwise if the + # existing device's NAME suggests it is the OOB and a host link can be + # demoted, offer promote; otherwise fall back to whichever is feasible. + if oob_type_from_libre and oob_possible: + result["serial_action"] = "oob_candidate" + elif host_possible and existing_oob_from_name: + result["serial_action"] = "promote_to_host" + elif oob_possible and host_possible: + # Both feasible but neither heuristic matches strongly -- + # default to oob_candidate (least-destructive), let the user flip. + result["serial_action"] = "oob_candidate" + elif oob_possible: + result["serial_action"] = "oob_candidate" + elif host_possible: + result["serial_action"] = "promote_to_host" + + # Surface the toggle availability for the template. When True the + # validation modal renders a Host/OOB radio next to the action button. + result["serial_role_choice_available"] = oob_possible and host_possible + + if oob_type_from_libre and not oob_possible: + # OOB-typed incoming but existing already has an OOB linked -- + # inform without blocking. No actionable button in this branch. result["serial_action"] = "link" - else: result["warnings"].append( - f"Device with same serial ({serial}) exists as '{existing_by_serial.name}' " - f"but hostname differs (LibreNMS: '{hostname}'). Device may have been reinstalled." + f"Device '{existing_by_serial.name}' already has an OOB controller linked. " + f"Re-import will update the existing OOB entry." + ) + elif not oob_possible and not host_possible: + # Neither role is feasible -- fall back to legacy hostname/serial + # warning behaviour so the user still sees a useful message. + if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower(): + if existing_link and existing_link["host_id"]: + result["warnings"].append( + f"Device with same serial and hostname exists as '{existing_by_serial.name}' " + f"(currently linked to LibreNMS device #{existing_link['host_id']})" + ) + else: + result["warnings"].append( + f"Device with same serial and hostname exists as '{existing_by_serial.name}' " + f"(not linked to LibreNMS)" + ) + result["serial_action"] = "link" + else: + result["warnings"].append( + f"Device with same serial ({serial}) exists as '{existing_by_serial.name}' " + f"but hostname differs (LibreNMS: '{hostname}'). Device may have been reinstalled." + ) + result["serial_action"] = "hostname_differs" + + # Refresh local variable to reflect any VM-mode adjustments made during detection + # (e.g. existing VM found by hostname sets result["import_as_vm"] = True). + # Must happen before the merge-candidates block below so a VM hostname-match + # doesn't fall through to Device-only merge logic. + import_as_vm = result["import_as_vm"] + + # Stage 2 β€” merge-candidates detection. + # When the hostname-matched device and the serial-matched device are + # DIFFERENT NetBox objects, the two probably represent the same + # physical box (host + OOB) imported as separate entries. Surface + # this as a merge action instead of silently picking one. + try: + _serial_for_pair = (libre_device.get("serial") or "").strip() + if ( + _serial_for_pair + and _serial_for_pair != "-" + and not import_as_vm + and result.get("existing_device") is not None + and result.get("existing_match_type") in ("hostname", "serial") + ): + _hostname_match = ( + result["existing_device"] if result.get("existing_match_type") == "hostname" else None + ) + _serial_match = result["existing_device"] if result.get("existing_match_type") == "serial" else None + # Whichever path landed first, look the other one up too. + if _hostname_match and not _serial_match: + _serial_match = ( + Device.objects.filter(serial=_serial_for_pair).exclude(pk=_hostname_match.pk).first() + ) + elif _serial_match and not _hostname_match and hostname: + _hostname_match = ( + Device.objects.filter(name__iexact=hostname).exclude(pk=_serial_match.pk).first() + ) + + if _hostname_match and _serial_match and _hostname_match.pk != _serial_match.pk: + host_link = _describe_existing_librenms_link(_hostname_match, server_key) + oob_link = _describe_existing_librenms_link(_serial_match, server_key) + # Conservative guard: at least one side must already be linked, + # otherwise this is more likely two unrelated devices that share + # serial data by coincidence (test fixtures, mis-keyed assets). + if (host_link and host_link["host_id"]) or (oob_link and oob_link["host_id"]): + result["serial_action"] = "merge_netbox_devices" + result["merge_candidates"] = { + "host_named": { + "pk": _hostname_match.pk, + "name": _hostname_match.name, + "librenms_link": host_link, + }, + "oob_named": { + "pk": _serial_match.pk, + "name": _serial_match.name, + "librenms_link": oob_link, + }, + } + result["can_import"] = False + result["warnings"].append( + f"Two NetBox devices appear to represent this physical box: " + f"'{_hostname_match.name}' (matches LibreNMS hostname) and " + f"'{_serial_match.name}' (matches chassis serial). " + f"Choose which one to keep and merge the other into it." ) - result["serial_action"] = "hostname_differs" + except Exception: # pragma: no cover - defensive: never break validation + logger.exception("merge-candidate detection failed") # Check by primary IP (weaker match, IP could be reassigned) - only for devices if not result["existing_device"]: @@ -477,16 +700,43 @@ def validate_device_for_import( else None ) if device: - result["existing_device"] = device - result["existing_match_type"] = "primary_ip" - result["warnings"].append( - f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)" + # Check if this is an OOB candidate via the IP path. + # The OOB controller's IP may already be the device's oob_ip, or the + # LibreNMS device may identify itself as an OOB type (iDRAC/iLO/etc.). + oob_type = normalize_oob_type( + libre_device.get("os", ""), + libre_device.get("hardware", ""), ) - result["can_import"] = False - - # Refresh local variable to reflect any VM-mode adjustments made during detection - # (e.g. existing VM found by hostname sets result["import_as_vm"] = True) - import_as_vm = result["import_as_vm"] + is_oob_ip = device.oob_ip_id is not None and existing_ip.pk == device.oob_ip_id + has_primary_ip = bool(device.primary_ip4_id or device.primary_ip6_id) + if oob_type and (is_oob_ip or not has_primary_ip): + existing_oob = get_librenms_oob(device, server_key=server_key) + if existing_oob is None: + result["existing_device"] = device + result["existing_match_type"] = "primary_ip" + result["serial_action"] = "oob_candidate" + result["oob_candidate"] = { + "device": device, + "type": oob_type, + "version": libre_device.get("version") or None, + "ip": libre_device.get("ip") or None, + } + result["can_import"] = False + else: + result["existing_device"] = device + result["existing_match_type"] = "primary_ip" + result["warnings"].append( + f"IP address {primary_ip} already assigned to device '{device.name}' " + f"(OOB already linked)" + ) + result["can_import"] = False + else: + result["existing_device"] = device + result["existing_match_type"] = "primary_ip" + result["warnings"].append( + f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)" + ) + result["can_import"] = False # Validate based on import type (Device or VM) if import_as_vm: @@ -617,7 +867,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: @@ -743,6 +1004,7 @@ def import_single_device( """ try: api = LibreNMSAPI(server_key=server_key) + created_ips: list[str] = [] # Use pre-fetched device data if provided, otherwise fetch from API if libre_device is None: @@ -883,6 +1145,24 @@ def import_single_device( device.full_clean() device.save() + # Pre-create the LibreNMS-known IP in IPAM (global /32 or /128) + # so the user can later attach it to an interface and assign as + # primary_ip4/6. We do not auto-set primary_ip4 here because + # NetBox's Device.clean() requires the IP be assigned to one of + # the device's interfaces, which doesn't exist on a fresh import. + primary_ip = libre_device.get("ip") + if primary_ip: + from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip + + _opts = sync_options or {} + if "auto_create_ipam" in _opts: + _auto_create = bool(_opts.get("auto_create_ipam")) + else: + _auto_create = auto_create_ipam_enabled() + _ip, was_created = get_or_create_global_ip(primary_ip, auto_create=_auto_create) + if was_created and _ip is not None: + created_ips.append(str(_ip.address.ip)) + # Sync additional data based on options sync_options = sync_options or {} synced = {"interfaces": 0, "cables": 0, "ip_addresses": 0} @@ -912,6 +1192,7 @@ def import_single_device( "message": f"Successfully imported device: {device.name}", "error": None, "synced": synced, + "created_ips": created_ips, } except Exception as e: diff --git a/netbox_librenms_plugin/import_utils/ip_helpers.py b/netbox_librenms_plugin/import_utils/ip_helpers.py new file mode 100644 index 0000000000..eec404813b --- /dev/null +++ b/netbox_librenms_plugin/import_utils/ip_helpers.py @@ -0,0 +1,111 @@ +""" +Helpers for ensuring LibreNMS-known IP addresses exist in NetBox IPAM. + +When the plugin links a LibreNMS device to a NetBox device (during initial +import, OOB attach, or promote-to-host), the IP that LibreNMS reaches the +device on may not yet exist in NetBox. To let users later assign that IP as +``primary_ip4`` / ``primary_ip6`` / ``oob_ip``, we first need an +``IPAddress`` record. This module centralises that logic so all import +paths behave the same way. + +The helper never overwrites an existing IPAM record: if an ``IPAddress`` +already exists for the host (matched via ``net_host`` so any prefix length +is acceptable), it is returned as-is. Only when no record exists is a new +``/32`` (IPv4) or ``/128`` (IPv6) entry created in the global scope. +""" + +from __future__ import annotations + +import logging +from ipaddress import ip_address as _ipaddr_parse +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - import only for type hints + from ipam.models import IPAddress + +logger = logging.getLogger(__name__) + + +def auto_create_ipam_enabled() -> bool: + """Return the value of the ``auto_create_ipam_default`` plugin setting. + + Defaults to ``False`` if the settings row does not exist or the field is + missing (e.g. during a migration). All callers should consult this before + asking ``get_or_create_global_ip(..., auto_create=True)`` so the user's + opt-in choice on the plugin settings page is honoured. + """ + try: + from netbox_librenms_plugin.models import LibreNMSSettings + + settings = LibreNMSSettings.objects.first() + return bool(getattr(settings, "auto_create_ipam_default", False)) if settings else False + except Exception: # pragma: no cover - defensive (migrations / startup) + return False + + +def get_or_create_global_ip(ip_str: str | None, *, auto_create: bool = True) -> "tuple[IPAddress | None, bool]": + """Return ``(ipam_record, created)`` for ``ip_str``. + + Creates a ``/32`` (IPv4) or ``/128`` (IPv6) global-scope record if no + matching ``IPAddress`` exists (matched via ``net_host`` so any prefix + length is acceptable). The returned ``IPAddress`` is unassigned (no + ``assigned_object``) and has no VRF. Callers may attach it to an + interface or assign it as ``oob_ip`` afterwards. + + The ``created`` flag is ``True`` only when a new IPAM record was + inserted by this call, so callers can surface a user-visible toast + only on creation (and stay silent when reusing an existing record). + + When ``auto_create`` is ``False``, no new record is ever inserted: + only existing records are returned. This lets callers honour the + ``auto_create_ipam_default`` plugin setting without having to + duplicate the lookup logic. + + Returns ``(None, False)`` if ``ip_str`` is empty, malformed, if + ``auto_create=False`` and no record exists, or if creation fails + (the failure is logged but never raised, so callers can treat this + as best-effort). + """ + if not ip_str: + return None, False + ip_str = ip_str.strip() + if not ip_str: + return None, False + + try: + parsed = _ipaddr_parse(ip_str) + except ValueError: + logger.debug("get_or_create_global_ip: invalid IP %r", ip_str) + return None, False + + from django.db import IntegrityError + + from ipam.models import IPAddress + + existing = IPAddress.objects.filter(address__net_host=ip_str, vrf__isnull=True).first() + if existing is not None: + return existing, False + + if not auto_create: + return None, False + + mask = "/128" if parsed.version == 6 else "/32" + try: + return IPAddress.objects.create(address=f"{ip_str}{mask}", status="active"), True + except IntegrityError: + # Concurrent create won the race; re-query the global record and return it. + existing = IPAddress.objects.filter(address__net_host=ip_str, vrf__isnull=True).first() + if existing is not None: + return existing, False + logger.warning( + "get_or_create_global_ip: IntegrityError but no global record found for %s", + ip_str, + ) + return None, False + except Exception: # pragma: no cover - defensive (validation etc.) + logger.warning( + "get_or_create_global_ip: failed to auto-create %s", + ip_str, + exc_info=True, + ) + return None, False diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py index a1b68ef41f..52b475b21e 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 a630b55632..0eef4224eb 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -22,6 +22,7 @@ def create_vm_from_librenms( use_sysname: bool = True, strip_domain: bool = False, role=None, + auto_create_ipam: bool | None = None, ): """ Create a NetBox VirtualMachine from LibreNMS device data. @@ -84,6 +85,22 @@ def create_vm_from_librenms( set_librenms_device_id(vm, librenms_device_id, server_key) vm.save() + # Pre-create the LibreNMS-known IP in IPAM (global /32 or /128) so + # the user can later attach it to a VM interface and assign it as + # primary_ip4/6. We do not auto-set primary_ip4 here because + # VirtualMachine.clean() requires the IP be assigned to one of the + # VM's interfaces, which doesn't exist on a fresh import. + primary_ip = libre_device.get("ip") + if primary_ip: + from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip + + _auto_create = auto_create_ipam_enabled() if auto_create_ipam is None else bool(auto_create_ipam) + _ip, was_created = get_or_create_global_ip(primary_ip, auto_create=_auto_create) + if was_created and _ip is not None: + # Stash for caller to surface via Django messages (best-effort; + # callers that don't read this attribute simply skip the toast). + vm._librenms_created_ips = [str(_ip.address.ip)] + logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}") return vm @@ -175,6 +192,7 @@ def bulk_import_vms( # 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 + auto_create_ipam_opt = sync_options.get("auto_create_ipam") if sync_options else None validation = validate_device_for_import( libre_device, import_as_vm=True, @@ -239,6 +257,7 @@ def bulk_import_vms( use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, server_key=api.server_key, + auto_create_ipam=auto_create_ipam_opt, ) result["success"].append( diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 457eea37f7..52bc96bd62 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -234,15 +234,12 @@ def get_librenms_id(self, obj): def _normalize_librenms_id(value): """Coerce a raw LibreNMS ID value to int or None. - Booleans are rejected because bool is a subclass of int in Python, - so int(True) silently becomes 1 β€” a valid-looking device ID. + Thin wrapper around :func:`netbox_librenms_plugin.utils.coerce_librenms_id` + kept for back-compat with internal callers in this module. """ - if value is None or isinstance(value, bool): - return None - try: - return int(value) - except (ValueError, TypeError): - return None + from netbox_librenms_plugin.utils import coerce_librenms_id + + return coerce_librenms_id(value) def _get_cache_key(self, obj): """ @@ -343,12 +340,11 @@ 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 + return True, device_data except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError): return False, None @@ -429,16 +425,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 +706,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 +1013,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 +1064,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 0000000000..9ef60b11db --- /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/migrations/0011_librenmssettings_auto_create_ipam_default.py b/netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py new file mode 100644 index 0000000000..ec6102dbd7 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_librenms_plugin", "0010_inventory_and_mapping_models"), + ] + + operations = [ + migrations.AddField( + model_name="librenmssettings", + name="auto_create_ipam_default", + field=models.BooleanField( + default=False, + help_text=( + "When enabled, missing IP addresses reported by LibreNMS are " + "auto-created as global /32 (IPv4) or /128 (IPv6) IPAM records " + "during initial import, OOB-attach, and promote-to-host actions. " + "Existing IPAM records are always reused." + ), + ), + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f47550..8453d7ef49 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,19 @@ class LibreNMSSettings(models.Model): help_text="Remove domain suffix from device names during import", ) + auto_create_ipam_default = models.BooleanField( + default=False, + help_text=( + "When enabled, missing IP addresses reported by LibreNMS are auto-created " + "as global /32 (IPv4) or /128 (IPv6) IPAM records during initial import, " + "OOB-attach, and promote-to-host actions. Existing IPAM records are always reused." + ), + ) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + class Meta: """Meta options for LibreNMSSettings.""" @@ -48,7 +106,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 +121,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 +149,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 a08e62740f..7a81a395a6 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/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index 68fb8d2641..ab6f0d96a5 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 // ============================================ @@ -275,8 +351,10 @@ function initializeTogglePrefs() { const sysname = document.getElementById('use-sysname-toggle'); const strip = document.getElementById('strip-domain-toggle'); + const ipam = document.getElementById('auto-create-ipam-toggle'); if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); }); if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); }); + if (ipam) ipam.addEventListener('change', function () { savePref('auto_create_ipam', this.checked); }); } // ============================================ @@ -1035,7 +1113,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 +1174,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,15 +1201,62 @@ 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. We also avoid + // preventDefault here so form submit buttons that happen to + // carry data-bs-dismiss="modal" in nested modals still submit. + const nearestModal = dismissTrigger.closest('.modal'); + if (nearestModal === modalElement) { + event.preventDefault(); hideModal(modalElement, fallbackBackdropRef); } } }); + // Refresh the validation modal in place (used after promote / OOB + // attach actions that mutate device link state but should leave the + // user inside the modal so they can see the new state). Also closes + // any nested modals (e.g. the Promote-to-host pick modal) before + // re-fetching so the user sees the refreshed validation directly. + document.body.addEventListener('validationRefresh', function (event) { + // Close any nested Bootstrap modals currently open inside the + // outer validation modal content. `window.bootstrap` is not + // exposed by NetBox so we fall back to plain DOM toggling. + document.querySelectorAll('#htmx-modal-content .modal.show').forEach(function (nested) { + try { + if (window.bootstrap) { + bootstrap.Modal.getOrCreateInstance(nested).hide(); + } else { + nested.classList.remove('show'); + nested.style.display = 'none'; + nested.setAttribute('aria-hidden', 'true'); + } + } catch (err) { + // Swallow - we still want to refresh the validation panel. + } + }); + + const deviceId = event.detail && (event.detail.deviceId || event.detail.device_id); + if (!deviceId) { + return; + } + const btn = document.querySelector( + 'tr#device-row-' + deviceId + ' button[hx-get*="/validation/' + deviceId + '/"]' + ); + if (btn) { + // htmx registers a delegated click handler on document, so a + // synthetic MouseEvent click on the row's "View details" + // button re-triggers the validation GET and swaps the new + // content into #htmx-modal-content. We cannot call + // `htmx.trigger()` directly because NetBox does not expose + // the htmx global to user scripts. + btn.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window})); + } + }); + // Handle backdrop clicks for HTMX modal modalElement?.addEventListener('click', function (event) { if (event.target === modalElement) { 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 1521b3d29e..7530fffdac 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,85 @@ 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(); + }) + .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 +1416,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 +1542,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 +1606,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 +1645,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 +1694,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 +1708,7 @@ function initializeModuleReplaceButtons() { if (modalContent) { modalContent.innerHTML = '' + ' {{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html new file mode 100644 index 0000000000..62db37c672 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html @@ -0,0 +1,36 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
NameMatch TypePatternActionRequire Serial MatchEnabledDescription
{{ object.name }}{{ object.get_match_type_display }}{% if object.match_type == "serial_matches_device" %}β€”{% else %}{{ object.pattern }}{% endif %}{{ object.get_action_display }}{% if object.match_type == "serial_matches_device" %}β€”{% elif object.require_serial_match_parent %}Yes{% else %}No{% endif %}{% if object.enabled %}Yes{% else %}No{% endif %}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html new file mode 100644 index 0000000000..13c0040d29 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html @@ -0,0 +1,35 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Inventory Ignore Rules

+

Configurable rules to skip ENTITY-MIB entries during module sync. + Some vendors (e.g. Cisco IOS-XR) report EEPROM/IDPROM chips as child + entities with the same model name and serial as their parent hardware. + These phantom entries would appear as duplicate modules in the sync UI.

+

Each rule matches an entity using one of the following strategies:

+
    +
  • ends_with / starts_with / contains / regex β€” matches the entity name string.
  • +
  • serial_matches_device β€” matches when the entity's serial number is identical to + the parent device's serial. Useful for suppressing EEPROM/IDPROM phantom entries that share the + device serial.
  • +
+

When Require Serial Match + is enabled (for name-based rules), the entry is only skipped if its serial number also matches the + parent entity β€” providing a safety net against accidentally hiding + legitimate modules.

+

Example β€” Cisco IOS-XR IDPROM entries:
+ Match type: ends_with, Pattern: IDPROM, + Require serial match: Yes
+ Skips entries like Optics0/0/0/0-IDPROM, + 0/FT0-FT IDPROM, Rack 0-Chassis IDPROM.

+
+ {{ block.super }} +{% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html index d1bdbd549c..c74b25ef7b 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html @@ -363,12 +363,20 @@
Strip domain +
+ + +
+ {% endif %} + {% if object.primary_ip6 %} + + {% endif %} + {% if object.oob_ip %} + + {% endif %} + + {% endif %} + + +{% endif %} + {% if all_server_mappings %}
@@ -73,6 +130,7 @@ {% if not mapping.is_configured %} {% if lookup_device_model_name == "device" or lookup_device_model_name == "virtualmachine" %} + {% if lookup_device_pk == object.pk %}
Remove
+ {% else %} + + Managed by VC sync device + + {% endif %} {% endif %} {% endif %} @@ -621,6 +684,14 @@
Device Information Sync
{% endif %} {% endwith %} + {% if module_sync and object|meta:"model_name" == "device" %} + + {% endif %}
Device Information Sync
{% include 'netbox_librenms_plugin/_ipaddress_sync.html' %} + {% if module_sync and object|meta:"model_name" == "device" %} +
+ {% include 'netbox_librenms_plugin/_module_sync.html' %} +
+ {% endif %} + {% with model_name=object|meta:"model_name" %} {% if model_name == "device" %}
Device Information Sync + + + {% if mismatched_device %}
+
+
+ {{ import_form.auto_create_ipam_default }} + + {% if import_form.auto_create_ipam_default.help_text %} + {{ import_form.auto_create_ipam_default.help_text }} + {% endif %} +
+
+ @@ -299,12 +311,14 @@

const saveImportBtn = document.getElementById('save-import-btn'); const useSysnameCheckbox = document.getElementById('id_use_sysname_default'); const stripDomainCheckbox = document.getElementById('id_strip_domain_default'); + const autoCreateIpamCheckbox = document.getElementById('id_auto_create_ipam_default'); // Store the initial values to detect changes const initialServerValue = serverSelect.value; const initialVcPatternValue = vcPatternInput.value; const initialUseSysnameValue = useSysnameCheckbox ? useSysnameCheckbox.checked : true; const initialStripDomainValue = stripDomainCheckbox ? stripDomainCheckbox.checked : false; + const initialAutoCreateIpamValue = autoCreateIpamCheckbox ? autoCreateIpamCheckbox.checked : false; // Enable/disable server save button based on changes function updateServerSaveButton() { @@ -319,7 +333,8 @@

const vcPatternChanged = vcPatternInput.value !== initialVcPatternValue; const useSysnameChanged = useSysnameCheckbox ? (useSysnameCheckbox.checked !== initialUseSysnameValue) : false; const stripDomainChanged = stripDomainCheckbox ? (stripDomainCheckbox.checked !== initialStripDomainValue) : false; - const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged; + const autoCreateIpamChanged = autoCreateIpamCheckbox ? (autoCreateIpamCheckbox.checked !== initialAutoCreateIpamValue) : false; + const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged || autoCreateIpamChanged; saveImportBtn.disabled = !hasChanges; saveImportBtn.classList.toggle('btn-primary', hasChanges); saveImportBtn.classList.toggle('btn-secondary', !hasChanges); @@ -330,6 +345,7 @@

vcPatternInput.addEventListener('input', updateImportSaveButton); if (useSysnameCheckbox) useSysnameCheckbox.addEventListener('change', updateImportSaveButton); if (stripDomainCheckbox) stripDomainCheckbox.addEventListener('change', updateImportSaveButton); + if (autoCreateIpamCheckbox) autoCreateIpamCheckbox.addEventListener('change', updateImportSaveButton); // Initialize button states updateServerSaveButton(); diff --git a/netbox_librenms_plugin/tests/mock_librenms_server.py b/netbox_librenms_plugin/tests/mock_librenms_server.py index 83a9258dba..7b07d6d521 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 f1d1bd2274..42adb15bb7 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_collisions.py b/netbox_librenms_plugin/tests/test_collisions.py new file mode 100644 index 0000000000..a98c8ce076 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_collisions.py @@ -0,0 +1,173 @@ +"""Unit tests for ``netbox_librenms_plugin.import_utils.collisions``.""" + +from types import SimpleNamespace + +from netbox_librenms_plugin.import_utils.collisions import detect_bulk_collisions + + +def _row(libre_id, hostname, validation): + return {"device_id": libre_id, "device_name": hostname, "validation": validation} + + +def test_no_devices_returns_empty(): + assert detect_bulk_collisions([]) == [] + assert detect_bulk_collisions(None) == [] + + +def test_no_overlap_returns_empty(): + devices = [ + _row(1, "alpha", {"existing_device": SimpleNamespace(pk=10, name="nb-alpha")}), + _row(2, "beta", {"existing_device": SimpleNamespace(pk=11, name="nb-beta")}), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_host_and_oob_collide_on_same_nb_device(): + nb_device = SimpleNamespace(pk=42, name="srv-01") + devices = [ + _row(100, "host-row", {"existing_device": nb_device}), + _row(101, "oob-row", {"oob_candidate": {"device": nb_device, "type": "idrac"}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + g = groups[0] + assert g["nb_device_pk"] == 42 + assert g["nb_device_name"] == "srv-01" + rows = {r["device_id"]: r for r in g["librenms_rows"]} + assert rows[100]["role"] == "host" + assert rows[101]["role"] == "oob" + + +def test_two_promote_targets_to_same_nb_device_collide(): + nb_device = SimpleNamespace(pk=7, name="core-sw") + devices = [ + _row(200, "row-a", {"promote_to_host": {"existing_device": nb_device}}), + _row(201, "row-b", {"promote_to_host": {"existing_device": nb_device}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert {r["device_id"] for r in groups[0]["librenms_rows"]} == {200, 201} + + +def test_merge_candidates_overlap_with_direct_existing(): + devices = [ + _row( + 300, + "row-merge", + { + "merge_candidates": { + "host_named": {"pk": 99, "name": "host-side"}, + "oob_named": {"pk": 100, "name": "oob-side"}, + } + }, + ), + _row(301, "row-direct", {"existing_device": SimpleNamespace(pk=99, name="host-side")}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert groups[0]["nb_device_pk"] == 99 + rows = {r["device_id"] for r in groups[0]["librenms_rows"]} + assert rows == {300, 301} + + +def test_promote_via_existing_device_pk_field(): + """promote_to_host may carry pk + name instead of a Device instance.""" + devices = [ + _row( + 400, + "row-a", + {"promote_to_host": {"existing_device_pk": 55, "existing_device_name": "shared"}}, + ), + _row(401, "row-b", {"existing_device": SimpleNamespace(pk=55, name="shared")}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert groups[0]["nb_device_pk"] == 55 + assert groups[0]["nb_device_name"] == "shared" + + +def test_three_way_collision(): + nb_device = SimpleNamespace(pk=8, name="busy") + devices = [ + _row(500, "a", {"existing_device": nb_device}), + _row(501, "b", {"oob_candidate": {"device": nb_device}}), + _row(502, "c", {"promote_to_host": {"existing_device": nb_device}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert {r["device_id"] for r in groups[0]["librenms_rows"]} == {500, 501, 502} + + +def test_same_libre_row_under_multiple_roles_does_not_collide_with_itself(): + """A single LibreNMS row whose validation lists the same NB pk under + multiple roles must NOT be reported as a collision β€” collisions + require at least two *distinct* LibreNMS device_ids.""" + nb_device = SimpleNamespace(pk=12, name="solo") + devices = [ + _row( + 600, + "lonely", + { + "existing_device": nb_device, + "oob_candidate": {"device": nb_device}, + }, + ), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_invalid_pks_are_skipped(): + devices = [ + _row(700, "a", {"existing_device": SimpleNamespace(pk=None, name="bad")}), + _row(701, "b", {"existing_device": SimpleNamespace(pk="not-int", name="worse")}), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_invalid_libre_id_is_skipped(): + """Rows with non-int device_id are dropped silently.""" + nb_device = SimpleNamespace(pk=20, name="nb") + devices = [ + _row("not-int", "weird", {"existing_device": nb_device}), + _row(800, "ok", {"existing_device": nb_device}), + ] + # Only one valid row -> no collision. + assert detect_bulk_collisions(devices) == [] + + +def test_groups_sorted_by_nb_pk_for_stable_render(): + nb_high = SimpleNamespace(pk=999, name="z") + nb_low = SimpleNamespace(pk=1, name="a") + devices = [ + _row(10, "x", {"existing_device": nb_high}), + _row(11, "y", {"existing_device": nb_high}), + _row(20, "p", {"existing_device": nb_low}), + _row(21, "q", {"existing_device": nb_low}), + ] + groups = detect_bulk_collisions(devices) + assert [g["nb_device_pk"] for g in groups] == [1, 999] + + +def test_row_roles_are_joined_when_same_libre_row_targets_pk_via_multiple_paths(): + """When LibreNMS row R touches NB pk P via both 'host' and + 'merge_host_named' (rare but possible during transitional states), + its row entry in the collision group should list both roles.""" + devices = [ + _row( + 900, + "dual", + { + "existing_device": SimpleNamespace(pk=77, name="shared"), + "merge_candidates": { + "host_named": {"pk": 77, "name": "shared"}, + "oob_named": {"pk": 78, "name": "other"}, + }, + }, + ), + _row(901, "neighbour", {"existing_device": SimpleNamespace(pk=77, name="shared")}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + rows = {r["device_id"]: r["role"] for r in groups[0]["librenms_rows"]} + assert "host" in rows[900] and "merge_host_named" in rows[900] + assert rows[901] == "host" diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index 50e8288eb9..93bad68976 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -57,6 +57,7 @@ def test_validation_error_returns_400(self): response = _save_device(device) assert response.status_code == 400 + assert b"Validation error" in response.content def test_integrity_error_returns_409(self): from django.db import IntegrityError @@ -69,6 +70,7 @@ def test_integrity_error_returns_409(self): response = _save_device(device) assert response.status_code == 409 + assert b"Integrity error" in response.content 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 @@ -442,6 +448,55 @@ def test_render_device_row_calls_render(self, mock_render): assert "device_import_row.html" in mock_render.call_args[0][1] +class TestAttachMessagesOob: + """Tests for the _attach_messages_oob helper.""" + + def test_returns_none_when_response_is_none(self): + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + assert _attach_messages_oob(None, MagicMock()) is None + + def test_skips_response_without_bytes_content(self): + """When .content is a MagicMock or similar non-bytes value, skip cleanly.""" + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = MagicMock() + response.content = MagicMock() # not bytes / bytearray + result = _attach_messages_oob(response, MagicMock()) + assert result is response # returned unchanged + + def test_appends_rendered_messages_to_bytes_content(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = HttpResponse(b"row html") + with patch( + "netbox_librenms_plugin.views.imports.actions.render_to_string", + return_value='
', + ) as mock_render: + result = _attach_messages_oob(response, MagicMock()) + + mock_render.assert_called_once() + assert b'
row html") + + def test_swallows_render_errors(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = HttpResponse(b"row html") + original = response.content + with patch( + "netbox_librenms_plugin.views.imports.actions.render_to_string", + side_effect=RuntimeError("db not available"), + ): + result = _attach_messages_oob(response, MagicMock()) + + assert result.content == original + + class TestDeviceValidationDetailsView: """Tests for DeviceValidationDetailsView (lines 477-822).""" @@ -665,11 +720,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 +756,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 +775,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 +801,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 +830,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 +844,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 +871,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 +1014,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 +1168,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 +1182,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 +1231,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 +1243,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 +1260,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 +1277,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 +1673,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 +1689,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 +1711,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 +1739,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 +1771,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 +1807,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 +1840,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 +1868,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 +1898,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 +1933,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 +1968,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 +2005,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 +2038,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 +2074,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 +2204,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 +2232,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 +2264,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 +2280,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 +2320,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: @@ -2329,7 +2441,7 @@ class TestSaveDevicePath: """Test _save_device IntegrityError and ValidationError paths (line 168).""" def test_save_device_validation_error(self): - """Lines 50-52: ValidationError during save.""" + """ValidationError during full_clean β†’ 400 response.""" from netbox_librenms_plugin.views.imports.actions import _save_device from django.core.exceptions import ValidationError as DjangoValidationError @@ -2339,9 +2451,10 @@ def test_save_device_validation_error(self): result = _save_device(mock_device) assert result is not None assert result.status_code == 400 + assert b"Validation error" in result.content def test_save_device_integrity_error(self): - """Lines 54-56: IntegrityError during save.""" + """IntegrityError during save β†’ 409 response.""" from netbox_librenms_plugin.views.imports.actions import _save_device from django.db import IntegrityError @@ -2351,7 +2464,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 == 409 + assert b"Integrity error" in result.content def test_should_enable_vc_detection_when_cached(self): """Line 168: VC data already cached β†’ returns True.""" @@ -2390,8 +2504,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 +2541,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 +2577,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 +2602,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 +2755,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 +2768,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 +2807,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 +2816,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 +2854,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 +2873,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 +2948,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 +2959,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 +3052,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 +3076,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 +3199,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 +3214,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 +3241,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 +3318,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 +3330,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 +3347,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 +3364,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 +3383,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 +3403,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 +3424,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 +3745,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 +4078,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 @@ -3953,3 +4129,106 @@ def get_side_effect(k, d=None): view.post(request) mock_redirect.assert_called() + + +class TestBulkImportConfirmCollisions: + """Tests for Stage 3 collision-blocking behaviour.""" + + def _make_view(self): + from netbox_librenms_plugin.views.imports.actions import BulkImportConfirmView + + view = object.__new__(BulkImportConfirmView) + view._librenms_api = _make_api() + return view + + def _run_with_two_devices(self, validation_a, validation_b): + """Drive BulkImportConfirmView.post with two LibreNMS rows whose + validations are stubbed to whatever the test wants. Returns the + actual response object returned by view.post().""" + view = self._make_view() + request = _make_request(post={"select": ["1", "2"]}) + request.POST.getlist = MagicMock(return_value=["1", "2"]) + request.GET = MagicMock() + request.GET.get = MagicMock(return_value=None) + + libre_devices = { + 1: {"device_id": 1, "hostname": "alpha"}, + 2: {"device_id": 2, "hostname": "beta"}, + } + validations = {1: validation_a, 2: validation_b} + + def fake_validate(libre_device, **_kwargs): + return validations[libre_device["device_id"]] + + with patch.object(view, "require_write_permission", return_value=None): + with patch( + "netbox_librenms_plugin.views.imports.actions.fetch_device_with_cache", + side_effect=lambda did, _api: libre_devices.get(did), + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.extract_device_selections", + return_value={"cluster_id": None, "role_id": None, "rack_id": None}, + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.validate_device_for_import", + side_effect=fake_validate, + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.resolve_naming_preferences", + return_value=(True, False), + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.render", + ) as mock_render: + mock_render.side_effect = lambda req, tpl, ctx, status=200: MagicMock( + status_code=status, + template_name=tpl, + context=ctx, + ) + response = view.post(request) + return response + + def test_collision_path_renders_collision_template_with_409(self): + from types import SimpleNamespace + + nb_device = SimpleNamespace(pk=42, name="srv-collide") + validation_a = { + "status": "importable", + "resolved_name": "alpha", + "virtual_chassis": {}, + "existing_device": nb_device, + } + validation_b = { + "status": "importable", + "resolved_name": "beta", + "virtual_chassis": {}, + "oob_candidate": {"device": nb_device, "type": "idrac"}, + } + response = self._run_with_two_devices(validation_a, validation_b) + # Response itself should be the collision template at status 409. + assert response is not None, "view.post returned None instead of a rendered response" + assert "bulk_import_collision.html" in response.template_name + assert response.status_code == 409 + assert len(response.context["collisions"]) == 1 + assert response.context["collisions"][0]["nb_device_pk"] == 42 + + def test_clean_batch_renders_normal_confirm_template(self): + from types import SimpleNamespace + + validation_a = { + "status": "importable", + "resolved_name": "alpha", + "virtual_chassis": {}, + "existing_device": SimpleNamespace(pk=1, name="nb-a"), + } + validation_b = { + "status": "importable", + "resolved_name": "beta", + "virtual_chassis": {}, + "existing_device": SimpleNamespace(pk=2, name="nb-b"), + } + response = self._run_with_two_devices(validation_a, validation_b) + assert response is not None, "view.post returned None instead of a rendered response" + assert "bulk_import_confirm.html" in response.template_name + # Default render() status is 200. + assert response.status_code == 200 diff --git a/netbox_librenms_plugin/tests/test_coverage_api.py b/netbox_librenms_plugin/tests/test_coverage_api.py index 9b8ff68293..3e00037fdf 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 65260421e6..68bd4ad728 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_views2.py b/netbox_librenms_plugin/tests/test_coverage_base_views2.py index e75abcc1b7..5e039a61d8 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 0000000000..2c92406b7f --- /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 137c70e85e..9e6247faaa 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 2c2848bdfb..50eebabff5 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: @@ -1665,3 +1679,699 @@ def test_chassis_match_overrides_hardware_match(self): device_patch.stop() assert result["device_type"].get("device_type") is chassis_dt + + +class TestOOBDetection: + """Tests for OOB candidate detection in validate_device_for_import (Phase 2).""" + + def _make_api(self, server_key="default"): + api = MagicMock() + api.server_key = server_key + return api + + def _base_patches(self, mock_device_cls, mock_vm_cls=None): + """Return a list of common patches (start/stop must be called by caller). + + Does NOT patch find_by_librenms_id β€” each test adds it explicitly so that + when the same target is patched twice the stop order (reversed) is clear. + """ + if mock_vm_cls is None: + mock_vm_cls = MagicMock() + mock_vm_cls.objects.filter.return_value.first.return_value = None + return [ + patch("netbox_librenms_plugin.import_utils.device_operations.Site"), + patch("netbox_librenms_plugin.import_utils.device_operations.DeviceType"), + patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_utils.device_operations.cache"), + patch("ipam.models.IPAddress"), + patch("virtualization.models.VirtualMachine", new=mock_vm_cls), + patch( + "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", + return_value={"matched": False}, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", + return_value={"found": False, "site": None, "match_type": None}, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", + return_value={"found": False, "platform": None, "match_type": None}, + ), + ] + + # ------------------------------------------------------------------ + # Case 1: Serial match + OOB regex β†’ oob_candidate + # ------------------------------------------------------------------ + def test_serial_match_oob_type_sets_oob_candidate(self): + """Serial matches + device os=idrac β†’ serial_action='oob_candidate', oob_candidate populated.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": 42}} + + mock_device_cls = MagicMock() + # hostname check β†’ None, serial check β†’ existing + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=None), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "oob_candidate" + assert result["oob_candidate"] is not None + assert result["oob_candidate"]["type"] == "idrac" + assert result["oob_candidate"]["ip"] == "10.0.0.5" + assert result["oob_candidate"]["version"] == "5.10.50" + assert result["can_import"] is False + + # ------------------------------------------------------------------ + # Case 2: Serial match + OOB regex + OOB already set β†’ serial_action="link" + # ------------------------------------------------------------------ + def test_serial_match_oob_already_linked_falls_back_to_link(self): + """Serial matches + OOB type + existing OOB already set β†’ serial_action='link'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": {"id": 42, "oob": {"id": 17, "type": "idrac"}}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + existing_oob = {"id": 17, "type": "idrac"} + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=existing_oob), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "link" + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 3: Serial match + NOT an OOB type β†’ link/hostname_differs unchanged + # ------------------------------------------------------------------ + def test_serial_match_non_oob_type_uses_standard_logic(self): + """Serial matches but os=linux β†’ standard serial_action (link or hostname_differs).""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "server01", + "sysName": "server01", + "hardware": "PowerEdge R640", + "serial": "ABC123", + "os": "linux", + "ip": "192.168.1.1", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + + mock_device_cls = MagicMock() + # hostname check β†’ None, serial check β†’ existing + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] in ("link", "hostname_differs") + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 4: result dict always has oob_candidate key (even when not set) + # ------------------------------------------------------------------ + def test_result_always_contains_oob_candidate_key(self): + """result dict always includes oob_candidate key regardless of detection path.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 1, + "hostname": "sw01", + "sysName": "sw01", + "hardware": "SomeSwitch", + "serial": "", + "os": "ios", + "ip": "", + "version": "", + "location": "", + } + api = self._make_api() + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert "oob_candidate" in result + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 4b: Inverse-OOB β€” existing NetBox device named like an OOB, + # already linked to a different LibreNMS id, incoming is the host. + # ------------------------------------------------------------------ + def test_serial_match_inverse_oob_sets_promote_to_host(self): + """Existing device named 'idrac-*' linked to libre #99; incoming host (os=linux) shares serial β†’ promote_to_host.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" + existing.cf = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "promote_to_host" + assert result["promote_to_host"] == { + "existing_libre_id": 99, + "existing_oob_type": "idrac", + } + assert result["existing_librenms_link"] == { + "host_id": 99, + "oob_id": None, + "oob_type": None, + } + assert result["can_import"] is False + + def test_serial_match_inverse_oob_skipped_when_existing_already_has_oob(self): + """If existing device already has an OOB linked, do NOT offer promote_to_host.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" + # Both host id and OOB already set β€” the merge has already happened. + existing.cf = {"librenms_id": {"default": {"id": 100, "oob": {"id": 99, "type": "idrac"}}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "promote_to_host" + assert result["promote_to_host"] is None + assert result["existing_librenms_link"]["oob_id"] == 99 + + def test_serial_role_choice_available_offers_both_options_when_feasible(self): + """When existing has a different LibreNMS host id linked, no OOB, and no name match, + both oob_candidate and promote_to_host are populated and serial_role_choice_available + signals the UI to render the host/OOB toggle.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" # OOB pattern in name -> heuristic picks promote + existing.cf = {"librenms_id": {"default": {"id": 25}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + # Heuristic chose promote_to_host (existing name suggests OOB, host link demote-able). + assert result["serial_action"] == "promote_to_host" + # Toggle is available so the user can flip to "Add as OOB" instead. + assert result["serial_role_choice_available"] is True + assert result["oob_candidate"] is not None + assert result["oob_candidate"]["device"] is existing + assert result["promote_to_host"] is not None + assert result["promote_to_host"]["existing_libre_id"] == 25 + + def test_serial_role_choice_not_available_when_names_match_and_no_link(self): + """Exact name match with no existing LibreNMS link -> simple link case, no toggle.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "server01", + "sysName": "server01", + "hardware": "PowerEdge R640", + "serial": "XYZ789", + "os": "linux", + "ip": "192.168.1.1", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.cf = {} # not linked + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "link" + assert result.get("serial_role_choice_available") is False + assert result["oob_candidate"] is None + assert result["promote_to_host"] is None + + def test_serial_match_inverse_oob_requires_oob_pattern_in_name(self): + """Existing device without OOB pattern in its name but with a different LibreNMS link + is now treated as an ambiguous host/OOB chassis pair: both options are populated and a + UI role-toggle is offered, defaulting to oob_candidate (least-destructive).""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "old-server-name" # no OOB pattern + existing.cf = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "oob_candidate" + assert result["serial_role_choice_available"] is True + assert result["oob_candidate"] is not None + assert result["promote_to_host"] is not None + assert result["promote_to_host"]["existing_libre_id"] == 99 + # existing link state is still surfaced + assert result["existing_librenms_link"]["host_id"] == 99 + + # ------------------------------------------------------------------ + # Stage 2: two-NetBox-device merge detection + # ------------------------------------------------------------------ + def test_merge_candidates_detected_when_hostname_and_serial_match_different_devices(self): + """Hostname matches device A, serial matches different device B (A has link) β†’ merge_netbox_devices.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" # same β€” skips serial-conflict path inside hostname branch + host_named.cf = {"librenms_id": {"default": {"id": 42}}} + host_named.custom_field_data = {"librenms_id": {"default": {"id": 42}}} + + oob_named = MagicMock() + oob_named.name = "idrac-jhw6nc4" + oob_named.pk = 200 + oob_named.serial = "ABC123" + oob_named.cf = {"librenms_id": {"default": {"id": 99}}} + oob_named.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + # filter().first() β€” hostname lookup β†’ host_named + mock_device_cls.objects.filter.return_value.first.return_value = host_named + # filter().exclude().first() β€” used by my merge-detect branch to find the serial-twin + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = oob_named + # isinstance(existing_device, Device) check β€” make it accept MagicMock objects. + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "merge_netbox_devices" + assert result["merge_candidates"] is not None + assert result["merge_candidates"]["host_named"]["pk"] == 100 + assert result["merge_candidates"]["host_named"]["name"] == "eve-ng-02" + assert result["merge_candidates"]["oob_named"]["pk"] == 200 + assert result["merge_candidates"]["oob_named"]["name"] == "idrac-jhw6nc4" + assert result["merge_candidates"]["host_named"]["librenms_link"]["host_id"] == 42 + assert result["merge_candidates"]["oob_named"]["librenms_link"]["host_id"] == 99 + assert result["can_import"] is False + + def test_merge_candidates_skipped_when_neither_device_has_librenms_link(self): + """Two different devices share serial but neither has librenms link β†’ conservative skip.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" + host_named.cf = {} # no librenms link + host_named.custom_field_data = {} + + oob_named = MagicMock() + oob_named.name = "idrac-jhw6nc4" + oob_named.pk = 200 + oob_named.serial = "ABC123" + oob_named.cf = {} + oob_named.custom_field_data = {} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = host_named + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = oob_named + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "merge_netbox_devices" + assert result["merge_candidates"] is None + + def test_merge_candidates_skipped_when_only_one_device(self): + """Hostname matches, no other device by serial β†’ no merge candidates.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" + host_named.cf = {"librenms_id": {"default": {"id": 42}}} + host_named.custom_field_data = {"librenms_id": {"default": {"id": 42}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = host_named + # No serial twin + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "merge_netbox_devices" + assert result["merge_candidates"] is None + + # ------------------------------------------------------------------ + # Case 5: Re-import via OOB id β†’ existing_match_type = "librenms_oob" + # ------------------------------------------------------------------ + def test_reimport_via_oob_id_sets_match_type_librenms_oob(self): + """find_by_librenms_id returns device when OOB id matches β†’ existing_match_type='librenms_oob'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + + # Simulate: find_by_librenms_id matched on OOB id (id=17 is oob.id) + existing_oob = {"id": 17, "type": "idrac"} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + # find_by_librenms_id: first call for VM returns None, second call for Device returns existing + find_by_id_mock = MagicMock(side_effect=[None, existing]) + + patches = self._base_patches(mock_device_cls) + [ + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + new=find_by_id_mock, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", + return_value=existing_oob, + ), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["existing_match_type"] == "librenms_oob" + assert result["existing_device"] is existing 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 0000000000..dab958f7ff --- /dev/null +++ b/netbox_librenms_plugin/tests/test_coverage_devices.py @@ -0,0 +1,952 @@ +"""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 netbox_librenms_plugin.views.object_sync.devices import SingleInterfaceVerifyView + from unittest.mock import MagicMock + + 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 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.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_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 2b4250ba56..deac03ff54 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 30b38dd38f..5cff3fbf6e 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 082a42e2c5..1ffb57c921 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().""" @@ -477,7 +504,7 @@ def test_get_job_id_loads_results(self): with patch.object(view, "get_server_info", return_value={}): view.get(request) - mock_load.assert_called_once_with(42) + mock_load.assert_called_once_with(42, request=request) def test_get_job_id_preserves_vc_flag_when_query_flag_missing(self): """job_id pages keep vc_detection_enabled from loaded job results.""" @@ -488,7 +515,7 @@ def test_get_job_id_preserves_vc_flag_when_query_flag_missing(self): mock_api = MagicMock() mock_api.server_key = "default" - def _load_job_side_effect(_job_id): + def _load_job_side_effect(_job_id, request=None): view._vc_detection_enabled = True return [{"device_id": 1, "hostname": "router1"}] @@ -516,7 +543,7 @@ def _load_job_side_effect(_job_id): with patch.object(view, "get_server_info", return_value={}): view.get(request) - mock_load.assert_called_once_with(42) + mock_load.assert_called_once_with(42, request=request) context = mock_render.call_args[0][2] assert context["vc_detection_enabled"] is True @@ -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 4ef4297c13..5c38e7594e 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_view.py b/netbox_librenms_plugin/tests/test_coverage_sync_view.py index c93647d0f7..36c6b0e7c5 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 042bceb916..552b67e252 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()): diff --git a/netbox_librenms_plugin/tests/test_coverage_sync_views2.py b/netbox_librenms_plugin/tests/test_coverage_sync_views2.py index e484babb10..12bc5464d7 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 b190a747f9..5f0fa2e003 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 db0f70f079..89d1cde680 100644 --- a/netbox_librenms_plugin/tests/test_import_utils.py +++ b/netbox_librenms_plugin/tests/test_import_utils.py @@ -1723,11 +1723,22 @@ def device_filter(*args, **kwargs): assert result["existing_match_type"] == "serial" assert "not linked to LibreNMS" in result["warnings"][0] - def test_serial_match_diff_hostname_offers_hostname_differs(self): - """Serial matches but hostname differs offers hostname_differs action.""" + def test_serial_match_diff_hostname_offers_role_choice(self): + """Serial matches but hostname differs offers a host/OOB role choice toggle. + + Previously this returned `hostname_differs`. With the role-toggle work + (see device_validation_details.html + device_operations.py refactor), + when an existing NetBox device matches by serial but the names differ + AND neither host nor OOB role is conclusively chosen by the heuristic, + we now expose both `oob_candidate` and `promote_to_host` data blocks + and surface a role-choice toggle in the UI. Default action is + `oob_candidate` (least destructive). + """ existing = MagicMock() existing.name = "old-hostname" existing.serial = "ABC123" + existing.pk = 7 + existing.custom_field_data = {} self.mock_vm.objects.filter.return_value.first.return_value = None @@ -1746,9 +1757,9 @@ def device_filter(*args, **kwargs): device_data = {"device_id": 1, "hostname": "new-hostname", "serial": "ABC123"} result = validate_device_for_import(device_data, include_vc_detection=False) - assert result["serial_action"] == "hostname_differs" + assert result["serial_action"] == "oob_candidate" assert result["existing_match_type"] == "serial" - assert "hostname differs" in result["warnings"][0] + assert result.get("oob_candidate") is not None def test_hostname_match_diff_serial_offers_update(self): """Hostname matches but serial differs offers update_serial action.""" @@ -2847,8 +2858,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 +2867,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 +2896,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 +2942,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 +2969,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") diff --git a/netbox_librenms_plugin/tests/test_init.py b/netbox_librenms_plugin/tests/test_init.py index fe2274fc95..f2c22797e5 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 a6dd1e98ed..cb89f6f84b 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_ip_helpers.py b/netbox_librenms_plugin/tests/test_ip_helpers.py new file mode 100644 index 0000000000..cde4d30fd4 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_ip_helpers.py @@ -0,0 +1,173 @@ +""" +Tests for netbox_librenms_plugin.import_utils.ip_helpers. + +All DB interactions are mocked β€” no @pytest.mark.django_db used. +""" + +from unittest.mock import MagicMock, patch + + +class TestGetOrCreateGlobalIP: + """Tests for ``get_or_create_global_ip``.""" + + def test_returns_none_for_empty_string(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + assert get_or_create_global_ip("") == (None, False) + assert get_or_create_global_ip(None) == (None, False) + assert get_or_create_global_ip(" ") == (None, False) + + def test_returns_none_for_invalid_ip(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + assert get_or_create_global_ip("not-an-ip") == (None, False) + assert get_or_create_global_ip("192.168.1.999") == (None, False) + + def test_returns_existing_when_present(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + existing = MagicMock(name="existing-ip") + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = existing + + result, created = get_or_create_global_ip("10.0.0.1") + + mock_model.objects.filter.assert_called_once_with(address__net_host="10.0.0.1", vrf__isnull=True) + mock_model.objects.create.assert_not_called() + assert result is existing + assert created is False + + def test_creates_ipv4_with_slash32_when_missing(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + created = MagicMock(name="created-ip") + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = None + mock_model.objects.create.return_value = created + + result, was_created = get_or_create_global_ip("10.0.0.1") + + mock_model.objects.create.assert_called_once_with(address="10.0.0.1/32", status="active") + assert result is created + assert was_created is True + + def test_creates_ipv6_with_slash128_when_missing(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + created = MagicMock(name="created-ip-v6") + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = None + mock_model.objects.create.return_value = created + + result, was_created = get_or_create_global_ip("2001:db8::1") + + mock_model.objects.create.assert_called_once_with(address="2001:db8::1/128", status="active") + assert result is created + assert was_created is True + + def test_strips_whitespace_before_lookup(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = None + mock_model.objects.create.return_value = MagicMock() + + result, was_created = get_or_create_global_ip(" 10.1.2.3 ") + + mock_model.objects.filter.assert_called_once_with(address__net_host="10.1.2.3", vrf__isnull=True) + mock_model.objects.create.assert_called_once_with(address="10.1.2.3/32", status="active") + assert was_created is True + + def test_integrity_error_on_create_returns_concurrently_created_record(self): + """A concurrent insert that races us is re-fetched and returned.""" + from django.db import IntegrityError + + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + racing_winner = MagicMock(name="racing-winner") + with patch("ipam.models.IPAddress") as mock_model: + # First filter (pre-create) β†’ None, second filter (post-IntegrityError) β†’ winner. + mock_model.objects.filter.return_value.first.side_effect = [None, racing_winner] + mock_model.objects.create.side_effect = IntegrityError("duplicate key") + + result, was_created = get_or_create_global_ip("10.0.0.1") + + assert result is racing_winner + assert was_created is False + + def test_returns_none_and_logs_when_create_raises(self, caplog): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = None + mock_model.objects.create.side_effect = RuntimeError("integrity failure") + + with caplog.at_level("WARNING"): + result, was_created = get_or_create_global_ip("10.0.0.1") + + assert result is None + assert was_created is False + assert any("failed to auto-create" in r.message for r in caplog.records) + + def test_reexported_from_import_utils_package(self): + """The helper is re-exported from the package root for convenience.""" + from netbox_librenms_plugin.import_utils import get_or_create_global_ip as pkg_helper + from netbox_librenms_plugin.import_utils.ip_helpers import ( + get_or_create_global_ip as module_helper, + ) + + assert pkg_helper is module_helper + + +class TestAutoCreateOptIn: + """``auto_create=False`` opt-out and ``auto_create_ipam_enabled()`` helper.""" + + def test_auto_create_false_returns_existing(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + existing = MagicMock(name="existing-ip") + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = existing + + result, created = get_or_create_global_ip("10.0.0.1", auto_create=False) + + assert result is existing + assert created is False + mock_model.objects.create.assert_not_called() + + def test_auto_create_false_skips_creation_when_missing(self): + from netbox_librenms_plugin.import_utils.ip_helpers import get_or_create_global_ip + + with patch("ipam.models.IPAddress") as mock_model: + mock_model.objects.filter.return_value.first.return_value = None + + result, created = get_or_create_global_ip("10.0.0.1", auto_create=False) + + assert result is None + assert created is False + mock_model.objects.create.assert_not_called() + + def test_auto_create_ipam_enabled_false_when_no_settings(self): + from netbox_librenms_plugin.import_utils.ip_helpers import auto_create_ipam_enabled + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_model: + mock_model.objects.first.return_value = None + assert auto_create_ipam_enabled() is False + + def test_auto_create_ipam_enabled_reads_field(self): + from netbox_librenms_plugin.import_utils.ip_helpers import auto_create_ipam_enabled + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_model: + settings = MagicMock(auto_create_ipam_default=True) + mock_model.objects.first.return_value = settings + assert auto_create_ipam_enabled() is True + + settings.auto_create_ipam_default = False + assert auto_create_ipam_enabled() is False + + def test_auto_create_ipam_enabled_swallows_exceptions(self): + from netbox_librenms_plugin.import_utils.ip_helpers import auto_create_ipam_enabled + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_model: + mock_model.objects.first.side_effect = RuntimeError("db down") + assert auto_create_ipam_enabled() is False diff --git a/netbox_librenms_plugin/tests/test_librenms_api.py b/netbox_librenms_plugin/tests/test_librenms_api.py index 24bc9e9213..e623cc17dc 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") @@ -1345,3 +1347,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 075e716105..78c2c33ed8 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,16 @@ 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__default__id", 42), + ("custom_field_data__librenms_id__default__id", "42"), + ("custom_field_data__librenms_id__default__oob__id", 42), + ("custom_field_data__librenms_id__default__oob__id", "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 +169,20 @@ 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__production__id", 999), + ("custom_field_data__librenms_id__production__id", "999"), + ("custom_field_data__librenms_id__production__oob__id", 999), + ("custom_field_data__librenms_id__production__oob__id", "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 +203,18 @@ 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__default__id", 42), + ("custom_field_data__librenms_id__default__id", "42"), + ("custom_field_data__librenms_id__default__oob__id", 42), + ("custom_field_data__librenms_id__default__oob__id", "42"), + ("custom_field_data__librenms_id", 42), + ("custom_field_data__librenms_id", "42"), + } class TestMigrateLegacyLibreNMSId: @@ -365,3 +384,349 @@ def test_unexpected_cf_type_reset_to_empty(self): obj.custom_field_data = {"librenms_id": "unexpected-string"} set_librenms_device_id(obj, 5, server_key="primary") assert obj.custom_field_data["librenms_id"] == {"primary": 5} + + +class TestOOBHelpers: + """Tests for get_librenms_oob, set_librenms_oob, clear_librenms_oob, + and the dict-with-id changes to get/set_librenms_device_id and find_by_librenms_id. + """ + + # ── get_librenms_device_id: dict-with-id form ───────────────────────────── + + def test_get_id_from_dict_with_id_form(self): + """get_librenms_device_id extracts 'id' from {"server": {"id": N}} form.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42}}} + assert get_librenms_device_id(obj, "primary") == 42 + + def test_get_id_when_oob_also_present(self): + """get_librenms_device_id returns the main id, ignoring the oob sub-object.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac"}}}} + assert get_librenms_device_id(obj, "primary") == 42 + + def test_get_returns_none_for_dict_without_id_key(self): + """dict entry without 'id' key returns None.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"oob": {"id": 17}}}} + assert get_librenms_device_id(obj, "primary") is None + + def test_get_normalises_string_id_inside_dict_with_id_form(self): + """String 'id' inside dict-with-id form is coerced to int.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": "42"}}} + obj.custom_field_data = {"librenms_id": {"primary": {"id": "42"}}} + result = get_librenms_device_id(obj, "primary", auto_save=False) + assert result == 42 + + # ── set_librenms_device_id: oob preservation ───────────────────────────── + + def test_set_preserves_oob_when_entry_has_oob(self): + """Updating main id preserves existing oob sub-object.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = { + "librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac", "ip": "10.0.0.5"}}} + } + set_librenms_device_id(obj, 99, server_key="primary") + assert obj.custom_field_data["librenms_id"] == { + "primary": {"id": 99, "oob": {"id": 17, "type": "drac", "ip": "10.0.0.5"}} + } + + def test_set_bare_int_when_no_oob_present(self): + """When no oob in existing entry, set_librenms_device_id stores bare int (no regression).""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + set_librenms_device_id(obj, 99, server_key="primary") + assert obj.custom_field_data["librenms_id"] == {"primary": 99} + + # ── find_by_librenms_id: dict-with-id and oob id lookups ───────────────── + + def test_find_by_matches_main_id_in_dict_with_id_form(self): + """find_by_librenms_id Q includes __id sub-key lookup.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + find_by_librenms_id(mock_model, 42, "primary") + + call_args = mock_model.objects.filter.call_args + q_arg = call_args[0][0] + assert ("custom_field_data__librenms_id__primary__id", 42) in q_arg.children + assert ("custom_field_data__librenms_id__primary__id", "42") in q_arg.children + + def test_find_by_matches_oob_id(self): + """find_by_librenms_id Q includes __oob__id sub-key lookup.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + find_by_librenms_id(mock_model, 17, "primary") + + call_args = mock_model.objects.filter.call_args + q_arg = call_args[0][0] + assert ("custom_field_data__librenms_id__primary__oob__id", 17) in q_arg.children + assert ("custom_field_data__librenms_id__primary__oob__id", "17") in q_arg.children + + def test_find_by_does_not_return_unrelated_id(self): + """find_by_librenms_id returns None when no model matches.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + result = find_by_librenms_id(mock_model, 999, "primary") + assert result is None + + # ── get_librenms_oob ────────────────────────────────────────────────────── + + def test_get_oob_returns_none_for_legacy_bare_int(self): + from netbox_librenms_plugin.utils import get_librenms_oob + + obj = MagicMock() + obj.cf = {"librenms_id": 42} + assert get_librenms_oob(obj, "primary") is None + + def test_get_oob_returns_none_for_bare_int_entry(self): + """When server-key entry is a bare int (no oob), returns None.""" + from netbox_librenms_plugin.utils import get_librenms_oob + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": 42}} + assert get_librenms_oob(obj, "primary") is None + + def test_get_oob_returns_oob_dict_when_present(self): + from netbox_librenms_plugin.utils import get_librenms_oob + + oob_data = {"id": 17, "type": "drac", "version": "5.10", "ip": "10.0.0.5"} + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42, "oob": oob_data}}} + result = get_librenms_oob(obj, "primary") + assert result == oob_data + + # ── set_librenms_oob ────────────────────────────────────────────────────── + + def test_set_oob_round_trip(self): + """set_librenms_oob followed by get_librenms_oob returns equivalent values.""" + from netbox_librenms_plugin.utils import get_librenms_oob, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + obj.cf = obj.custom_field_data + + set_librenms_oob(obj, 17, "primary", oob_type="drac", version="5.10", ip="10.0.0.5") + result = get_librenms_oob(obj, "primary") + + assert result == {"id": 17, "type": "drac", "version": "5.10", "ip": "10.0.0.5"} + + def test_set_oob_promotes_bare_int_entry(self): + """set_librenms_oob promotes a bare-int entry to dict form, preserving the main id.""" + from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + obj.cf = obj.custom_field_data + + set_librenms_oob(obj, 17, "primary", oob_type="idrac") + assert get_librenms_device_id(obj, "primary") == 42 + + def test_set_oob_rejects_unknown_type(self): + """set_librenms_oob raises ValueError for a type that doesn't match OOB_TYPE_PATTERN.""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + + import pytest + + with pytest.raises(ValueError, match="does not match any known OOB type"): + set_librenms_oob(obj, 17, "primary", oob_type="ubuntu") + + def test_set_oob_does_not_call_save(self): + """set_librenms_oob must NOT call obj.save() β€” caller is responsible.""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + + set_librenms_oob(obj, 17, "primary", oob_type="ilo") + obj.save.assert_not_called() + + # ── clear_librenms_oob ──────────────────────────────────────────────────── + + def test_clear_oob_removes_oob_sub_key(self): + from netbox_librenms_plugin.utils import clear_librenms_oob, get_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac"}}}} + obj.cf = obj.custom_field_data + + clear_librenms_oob(obj, "primary") + assert get_librenms_oob(obj, "primary") is None + # Main id should still be accessible via dict-with-id form + assert obj.custom_field_data["librenms_id"]["primary"] == {"id": 42} + + def test_clear_oob_is_noop_when_no_oob(self): + """clear_librenms_oob is a no-op when oob key is not present.""" + from netbox_librenms_plugin.utils import clear_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42}}} + + clear_librenms_oob(obj, "primary") + assert obj.custom_field_data["librenms_id"] == {"primary": {"id": 42}} + + def test_clear_oob_does_not_call_save(self): + """clear_librenms_oob must NOT call obj.save() β€” caller is responsible.""" + from netbox_librenms_plugin.utils import clear_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "bmc"}}}} + + clear_librenms_oob(obj, "primary") + obj.save.assert_not_called() + + +class TestMergeLibreNMSLinks: + """Tests for merge_librenms_links() β€” winner-wins conflict policy.""" + + def _make_dev(self, name, librenms_id_dict): + d = MagicMock() + d.name = name + d.custom_field_data = {"librenms_id": librenms_id_dict} if librenms_id_dict is not None else {} + return d + + def test_winner_inherits_id_when_winner_has_no_id(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {}}) + donor = self._make_dev("idrac-jhw6nc4", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 99 + assert summary["host_id_from_donor"] == 99 + assert summary["donor_id_demoted_to_oob"] is None + + def test_donor_id_demoted_to_oob_when_winner_has_id_and_donor_name_matches_oob_pattern(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("idrac-jhw6nc4", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 42 + assert winner.custom_field_data["librenms_id"]["default"]["oob"]["id"] == 99 + assert winner.custom_field_data["librenms_id"]["default"]["oob"]["type"] == "idrac" + assert summary["donor_id_demoted_to_oob"] == {"id": 99, "type": "idrac"} + + def test_donor_id_skipped_when_no_oob_pattern_in_donor_name(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("eve-ng-03-spare", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 42 + assert "oob" not in winner.custom_field_data["librenms_id"]["default"] + assert summary["donor_id_demoted_to_oob"] is None + + def test_winner_inherits_donor_oob_when_winner_has_none(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("eve-ng-02-old", {"default": {"oob": {"id": 77, "type": "ipmi"}}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["oob"] == {"id": 77, "type": "ipmi"} + assert summary["oob_from_donor"] == {"id": 77, "type": "ipmi"} + + def test_winner_oob_never_overwritten(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42, "oob": {"id": 11, "type": "drac"}}}) + donor = self._make_dev("eve-ng-02-old", {"default": {"oob": {"id": 77, "type": "ipmi"}}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["oob"] == {"id": 11, "type": "drac"} + assert summary["oob_from_donor"] is None + + def test_legacy_bare_int_raises(self): + import pytest + + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = MagicMock() + winner.custom_field_data = {"librenms_id": 42} + donor = self._make_dev("idrac-x", {"default": {"id": 99}}) + with pytest.raises(ValueError): + merge_librenms_links(winner, donor, "default") + + +class TestMarkLibreNMSMigrated: + """Tests for mark_librenms_migrated().""" + + def test_clears_id_and_oob_and_writes_marker(self): + from netbox_librenms_plugin.utils import mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99, "oob": {"id": 11, "type": "drac"}}}} + mark_librenms_migrated(donor, winner_pk=42, server_key="default", at="2025-01-01T00:00:00Z") + + entry = donor.custom_field_data["librenms_id"]["default"] + assert "id" not in entry + assert "oob" not in entry + assert entry["_migrated_to"] == { + "device_id": 42, + "server_key": "default", + "at": "2025-01-01T00:00:00Z", + } + + def test_default_timestamp_is_iso_z(self): + from netbox_librenms_plugin.utils import mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + mark_librenms_migrated(donor, winner_pk=42, server_key="default") + + ts = donor.custom_field_data["librenms_id"]["default"]["_migrated_to"]["at"] + assert ts.endswith("Z") + assert len(ts) == 20 + + def test_after_marker_find_by_librenms_id_no_longer_matches(self): + """Donor with only _migrated_to should not be returned by find_by_librenms_id.""" + from netbox_librenms_plugin.utils import find_by_librenms_id, mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + donor.cf = donor.custom_field_data + mark_librenms_migrated(donor, winner_pk=42, server_key="default") + + # cf.librenms_id[default] now only has _migrated_to β€” no id, no oob. + # find_by_librenms_id walks cf via the model query, but logic-wise: simulate + # by directly inspecting the entry. + entry = donor.cf["librenms_id"]["default"] + assert entry.get("id") is None + assert entry.get("oob") is None + # Mock model.objects.filter: should return empty queryset for either id or oob lookup + mock_model = MagicMock() + mock_model.objects.filter.return_value.first.return_value = None + assert find_by_librenms_id(mock_model, 99, "default") is None diff --git a/netbox_librenms_plugin/tests/test_migrate_views.py b/netbox_librenms_plugin/tests/test_migrate_views.py new file mode 100644 index 0000000000..95d60b3838 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_migrate_views.py @@ -0,0 +1,323 @@ +""" +Tests for Stage 2b: get_migrated_to_marker helper + the per-row +"Move to winner" view endpoints (MoveInterfaceToWinnerView, +MoveIPAddressToWinnerView, TransferDeviceIPView). + +The view tests use light MagicMock-based plumbing β€” we patch the model +queryset chain so the views never touch a real database. This is +appropriate because the views' job is glue: validate the marker, look +up the winner, run a small ORM mutation, and return an HTMX response. +""" + +from unittest.mock import MagicMock, patch + + +# ── helper: get_migrated_to_marker ──────────────────────────────────────── + + +class TestGetMigratedToMarker: + def test_returns_marker_when_present_and_well_formed(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = { + "librenms_id": { + "default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "2025-01-01T00:00:00Z"}} + } + } + marker = get_migrated_to_marker(donor, "default") + assert marker == {"device_id": 42, "server_key": "default", "at": "2025-01-01T00:00:00Z"} + + def test_returns_none_when_no_cf(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_server_key_missing(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": {"primary": {"_migrated_to": {"device_id": 42}}}} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_marker_lacks_device_id(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"server_key": "default"}}}} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_legacy_bare_int(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": 42} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_for_none_device(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + assert get_migrated_to_marker(None, "default") is None + + +# ── helper: _resolve_winner_for_donor ───────────────────────────────────── + + +class TestResolveWinnerForDonor: + def test_returns_winner_and_marker_when_both_exist(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "x"}}}} + winner = MagicMock(pk=42) + + with patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device: + mock_device.objects.filter.return_value.first.return_value = winner + result_winner, result_marker = _resolve_winner_for_donor(donor, "default") + + assert result_winner is winner + assert result_marker["device_id"] == 42 + + def test_returns_none_winner_when_winner_deleted(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "x"}}}} + + with patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device: + mock_device.objects.filter.return_value.first.return_value = None + winner, marker = _resolve_winner_for_donor(donor, "default") + + assert winner is None + assert marker["device_id"] == 42 + + def test_returns_none_when_no_marker(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {} + winner, marker = _resolve_winner_for_donor(donor, "default") + assert winner is None + assert marker is None + + +# ── MoveInterfaceToWinnerView ───────────────────────────────────────────── + + +def _hx_request(post=None): + """Build an HTMX request.""" + req = MagicMock() + post = post or {} + req.POST = MagicMock() + req.POST.get = lambda k, d=None: post.get(k, d) + req.headers = {"HX-Request": "true"} + req.META = {"HTTP_REFERER": "/back"} + req.user = MagicMock(is_superuser=True) + return req + + +class TestMoveInterfaceToWinnerView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import MoveInterfaceToWinnerView + + view = MoveInterfaceToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_rejects_when_donor_has_no_marker(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10, cf={}) + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(None, None), + ), + ): + resp = view.post(req, pk=5) + + assert resp.status_code == 409 + + def test_rejects_on_name_collision(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Interface") as mock_iface_cls, + ): + mock_iface_cls.objects.filter.return_value.exists.return_value = True + resp = view.post(req, pk=5) + + assert resp.status_code == 409 + + def test_happy_path_reassigns_device_and_returns_hx_refresh(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Interface") as mock_iface_cls, + patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.sync.migrate.transaction"), + patch("netbox_librenms_plugin.views.sync.migrate.messages"), + ): + mock_iface_cls.objects.filter.return_value.exists.return_value = False + mock_device_cls.objects.select_for_update.return_value.filter.return_value.order_by.return_value = [] + resp = view.post(req, pk=5) + + assert interface.device is winner + interface.save.assert_called_once() + assert resp.headers.get("HX-Refresh") == "true" + + def test_perm_gate_short_circuits(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.sync.migrate import MoveInterfaceToWinnerView + + view = MoveInterfaceToWinnerView() + view.require_all_permissions = MagicMock(return_value=HttpResponse(status=403)) + req = _hx_request() + resp = view.post(req, pk=5) + assert resp.status_code == 403 + + +# ── TransferDeviceIPView ────────────────────────────────────────────────── + + +class TestTransferDeviceIPView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import TransferDeviceIPView + + view = TransferDeviceIPView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_unknown_ip_kind_rejected(self): + view = self._setup_view() + req = _hx_request() + resp = view.post(req, pk=10, ip_kind="bogus") + assert resp.status_code == 400 + + def test_rejects_when_winner_already_has_field(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor_ip = MagicMock(address="10.0.0.1/24") + donor = MagicMock(pk=10, primary_ip4=donor_ip) + winner = MagicMock(pk=20, name="winner", primary_ip4=MagicMock(address="10.0.0.99/24")) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=donor), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + ): + resp = view.post(req, pk=10, ip_kind="primary4") + + assert resp.status_code == 409 + + def test_happy_path_transfers_oob_ip(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + oob_ip = MagicMock(address="10.0.0.5/24") + donor = MagicMock(pk=10, oob_ip=oob_ip) + winner = MagicMock(pk=20, name="winner", oob_ip=None) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=donor), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.sync.migrate.transaction"), + patch("netbox_librenms_plugin.views.sync.migrate.messages"), + ): + mock_device_cls.objects.select_for_update.return_value.filter.return_value.order_by.return_value = [] + resp = view.post(req, pk=10, ip_kind="oob") + + assert winner.oob_ip is oob_ip + assert donor.oob_ip is None + winner.save.assert_called_once() + donor.save.assert_called_once() + assert resp.headers.get("HX-Refresh") == "true" + + +# ── MoveIPAddressToWinnerView ──────────────────────────────────────────── + + +class TestMoveIPAddressToWinnerView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import MoveIPAddressToWinnerView + + view = MoveIPAddressToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_rejects_when_ip_not_assigned_to_interface(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + ip = MagicMock(pk=7) + ip.assigned_object = None # not an Interface + + with patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=ip): + resp = view.post(req, pk=7) + + assert resp.status_code == 409 + + def test_rejects_when_winner_lacks_same_name_interface(self): + from dcim.models import Interface + + from netbox_librenms_plugin.views.sync.migrate import MoveIPAddressToWinnerView + + view = MoveIPAddressToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + donor_iface = MagicMock(spec=Interface) + donor_iface.name = "Eth0" + donor_iface.device = donor + ip = MagicMock(pk=7, address="10.0.0.1/24") + ip.assigned_object = donor_iface + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=ip), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch.object(Interface, "objects") as mock_objects, + ): + mock_objects.filter.return_value.first.return_value = None + resp = view.post(req, pk=7) + + assert resp.status_code == 409 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 0000000000..802f88cad8 --- /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 0000000000..d4306fdfab --- /dev/null +++ b/netbox_librenms_plugin/tests/test_modules_view.py @@ -0,0 +1,3797 @@ +""" +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 + + +# --------------------------------------------------------------------------- +# 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_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): + b = MagicMock() + b.name = name + return b + + 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_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 + + +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_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_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 " platform.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + assert str(mapping) == "ios -> Cisco IOS" + + def test_clean_strips_whitespace(self): + """clean() strips leading/trailing whitespace from librenms_os.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = " ios " + mapping.description = "" + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_os == "ios" + + def test_clean_normalizes_to_lowercase(self): + """clean() lowercases librenms_os to prevent case-variant duplicates.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "IOS" + mapping.description = "" + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_os == "ios" + + def test_clean_strips_and_lowercases(self): + """clean() strips and lowercases in one pass.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = " EOS " + mapping.description = "" + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_os == "eos" + + def test_clean_raises_on_blank(self): + """clean() raises ValidationError when librenms_os is blank after strip.""" + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = " " + mapping.description = "" + + import pytest + + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "librenms_os" in str(exc_info.value) + + def test_get_absolute_url(self): + """get_absolute_url returns correct URL.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.pk = 42 + + with patch("netbox_librenms_plugin.models.reverse") as mock_reverse: + mock_reverse.return_value = "/plugins/librenms/platform-mappings/42/" + url = mapping.get_absolute_url() + mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:platformmapping_detail", args=[42]) + assert url == "/plugins/librenms/platform-mappings/42/" + + def test_meta_ordering(self): + """Model Meta ordering is by librenms_os.""" + from netbox_librenms_plugin.models import PlatformMapping + + assert PlatformMapping._meta.ordering == ["librenms_os"] + + +# ============================================================================= +# TestDeviceTypeMappingModel +# ============================================================================= + + +class TestDeviceTypeMappingModel: + """Tests for DeviceTypeMapping.clean() normalization.""" + + def test_clean_strips_whitespace(self): + """clean() strips leading/trailing whitespace from librenms_hardware.""" + from netbox_librenms_plugin.models import DeviceTypeMapping + + mapping = DeviceTypeMapping.__new__(DeviceTypeMapping) + mapping.librenms_hardware = " Cisco 4321 " + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_hardware == "cisco 4321" + + def test_clean_normalizes_to_lowercase(self): + """clean() lowercases librenms_hardware to prevent case-variant duplicates.""" + from netbox_librenms_plugin.models import DeviceTypeMapping + + mapping = DeviceTypeMapping.__new__(DeviceTypeMapping) + mapping.librenms_hardware = "Juniper MX480" + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_hardware == "juniper mx480" + + def test_clean_raises_on_blank(self): + """clean() raises ValidationError when librenms_hardware is blank after strip.""" + import pytest + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import DeviceTypeMapping + + mapping = DeviceTypeMapping.__new__(DeviceTypeMapping) + mapping.librenms_hardware = " " + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "librenms_hardware" in str(exc_info.value) + + +class TestPlatformMappingToYaml: + """to_yaml() returns valid YAML string with expected keys.""" + + def test_to_yaml_returns_string(self): + """to_yaml() returns a string.""" + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + mapping.description = "Test description" + + result = mapping.to_yaml() + assert isinstance(result, str) + + def test_to_yaml_contains_expected_keys(self): + """to_yaml() output contains librenms_os, netbox_platform, description.""" + import yaml + + from netbox_librenms_plugin.models import PlatformMapping + + mapping = PlatformMapping.__new__(PlatformMapping) + mapping.librenms_os = "ios" + platform = MagicMock() + platform.__str__ = lambda s: "Cisco IOS" + _set_fk_cache(mapping, "netbox_platform", platform) + mapping.description = "A description" + + result = yaml.safe_load(mapping.to_yaml()) + assert result["librenms_os"] == "ios" + assert result["netbox_platform"] == "Cisco IOS" + assert result["description"] == "A description" + + +# ============================================================================= +# TestToYamlOnAllMappingModels +# ============================================================================= + + +class TestToYamlOnAllMappingModels: + """All mapping models must have to_yaml() returning a YAML string.""" + + def test_device_type_mapping_has_to_yaml(self): + """DeviceTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import DeviceTypeMapping + + mapping = DeviceTypeMapping.__new__(DeviceTypeMapping) + mapping.librenms_hardware = "Cisco 4321" + device_type = MagicMock() + device_type.__str__ = lambda s: "Cisco 4321" + _set_fk_cache(mapping, "netbox_device_type", device_type) + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_hardware" in data + assert data["librenms_hardware"] == "Cisco 4321" + + def test_module_type_mapping_has_to_yaml(self): + """ModuleTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import ModuleTypeMapping + + mapping = ModuleTypeMapping.__new__(ModuleTypeMapping) + mapping.librenms_model = "WS-X4748-RJ45" + module_type = MagicMock() + module_type.__str__ = lambda s: "WS-X4748" + _set_fk_cache(mapping, "netbox_module_type", module_type) + mapping.manufacturer_id = None + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_model" in data + + def test_interface_type_mapping_has_to_yaml(self): + """InterfaceTypeMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import InterfaceTypeMapping + + mapping = InterfaceTypeMapping.__new__(InterfaceTypeMapping) + mapping.librenms_type = "ether" + mapping.librenms_speed = 1000000 + mapping.netbox_type = "1000base-t" + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_type" in data + + def test_module_bay_mapping_has_to_yaml(self): + """ModuleBayMapping.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = "Slot 1" + mapping.librenms_class = "container" + mapping.netbox_bay_name = "Slot 1" + mapping.is_regex = False + mapping.manufacturer_id = None + _set_fk_cache(mapping, "manufacturer", None) + mapping.description = "" + + result = mapping.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "librenms_name" in data + + def test_normalization_rule_has_to_yaml(self): + """NormalizationRule.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.scope = "device_type" + _set_fk_cache(rule, "manufacturer", None) + rule.match_pattern = r"^Cisco\s+" + rule.replacement = "Cisco" + rule.priority = 10 + rule.description = "" + + result = rule.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "scope" in data + assert "match_pattern" in data + + def test_inventory_ignore_rule_has_to_yaml(self): + """InventoryIgnoreRule.to_yaml() returns a YAML string.""" + import yaml + + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.name = "Skip IDPROM" + rule.match_type = "ends_with" + rule.pattern = "IDPROM" + rule.action = "skip" + rule.require_serial_match_parent = False + rule.enabled = True + rule.description = "" + + result = rule.to_yaml() + assert isinstance(result, str) + data = yaml.safe_load(result) + assert "name" in data + assert "match_type" in data + + +# ============================================================================= +# TestFindMatchingPlatformWithMapping +# ============================================================================= + + +class TestFindMatchingPlatformWithMapping: + """find_matching_platform checks PlatformMapping before direct name match.""" + + def test_platform_mapping_used_as_fallback_when_no_name_match(self): + """When no Platform name matches, PlatformMapping is used as fallback.""" + from netbox_librenms_plugin.utils import find_matching_platform + + mock_mapped_platform = MagicMock(name="mapped_platform") + mock_mapping = MagicMock() + mock_mapping.netbox_platform = mock_mapped_platform + + mock_pm_class = MagicMock() + mock_pm_class.objects.get.return_value = mock_mapping + mock_pm_class.DoesNotExist = type("DoesNotExist", (Exception,), {}) + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_platform_model.objects.get.side_effect = mock_platform_model.DoesNotExist + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is mock_mapped_platform + assert result["match_type"] == "mapping" + + def test_falls_back_to_name_match_when_no_platform_mapping(self): + """When no PlatformMapping exists, falls back to exact Platform name match.""" + from netbox_librenms_plugin.utils import find_matching_platform + + mock_platform = MagicMock() + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_pm_class.objects.get.side_effect = mock_pm_class.DoesNotExist + + mock_platform_model = MagicMock() + mock_platform_model.objects.get.return_value = mock_platform + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is mock_platform + assert result["match_type"] == "exact" + + def test_returns_not_found_when_neither_mapping_nor_platform(self): + """Returns found=False when neither PlatformMapping nor Platform name match exists.""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.objects.get.side_effect = DoesNotExist + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.objects.get.side_effect = DoesNotExist + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("unknown_os") + + assert result["found"] is False + assert result["platform"] is None + + def test_multiple_platform_mappings_returns_ambiguous(self): + """When exact name fails and PlatformMapping.MultipleObjectsReturned, returns ambiguous.""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.MultipleObjectsReturned = MultipleObjectsReturned + mock_pm_class.objects.get.side_effect = MultipleObjectsReturned + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.objects.get.side_effect = DoesNotExist + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result == {"found": False, "platform": None, "match_type": "ambiguous", "ambiguity_source": "mapping"} + + def test_exact_platform_wins_over_existing_mapping(self): + """When a single Platform exists for the OS string, the exact match wins even if a PlatformMapping also exists.""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) + + # Both an exact Platform and a PlatformMapping exist for "ios" β€” they would + # resolve to *different* Platform objects. Exact match must win; the mapping + # branch must never be consulted. + exact_platform = MagicMock(name="exact_platform") + mapping_platform = MagicMock(name="mapping_platform") + mapping = MagicMock(netbox_platform=mapping_platform) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.MultipleObjectsReturned = MultipleObjectsReturned + mock_pm_class.objects.get.return_value = mapping + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.MultipleObjectsReturned = MultipleObjectsReturned + mock_platform_model.objects.get.return_value = exact_platform + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is exact_platform + assert result["match_type"] == "exact" + # Mapping must not be consulted when exact resolves cleanly. + mock_pm_class.objects.get.assert_not_called() + + def test_ambiguous_platform_falls_through_to_mapping_resolution(self): + """When Platform.MultipleObjectsReturned fires, PlatformMapping is consulted; if it resolves, mapping wins (not 'ambiguous').""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) + + mapping_platform = MagicMock(name="mapping_platform") + mapping = MagicMock(netbox_platform=mapping_platform) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.MultipleObjectsReturned = MultipleObjectsReturned + mock_pm_class.objects.get.return_value = mapping + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.MultipleObjectsReturned = MultipleObjectsReturned + # Exact lookup is ambiguous β€” but mapping resolves cleanly, so mapping wins. + mock_platform_model.objects.get.side_effect = MultipleObjectsReturned + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result["found"] is True + assert result["platform"] is mapping_platform + assert result["match_type"] == "mapping" + + def test_ambiguous_platform_with_no_mapping_returns_platform_ambiguous(self): + """When Platform.MultipleObjectsReturned fires and PlatformMapping has no entry, ambiguity_source='platform'.""" + from netbox_librenms_plugin.utils import find_matching_platform + + DoesNotExist = type("DoesNotExist", (Exception,), {}) + MultipleObjectsReturned = type("MultipleObjectsReturned", (Exception,), {}) + + mock_pm_class = MagicMock() + mock_pm_class.DoesNotExist = DoesNotExist + mock_pm_class.MultipleObjectsReturned = MultipleObjectsReturned + mock_pm_class.objects.get.side_effect = DoesNotExist + + mock_platform_model = MagicMock() + mock_platform_model.DoesNotExist = DoesNotExist + mock_platform_model.MultipleObjectsReturned = MultipleObjectsReturned + mock_platform_model.objects.get.side_effect = MultipleObjectsReturned + + with ( + patch("netbox_librenms_plugin.models.PlatformMapping", mock_pm_class), + patch("dcim.models.Platform", mock_platform_model), + ): + result = find_matching_platform("ios") + + assert result == {"found": False, "platform": None, "match_type": "ambiguous", "ambiguity_source": "platform"} + + +# ============================================================================= +# TestBulkExportYAMLView +# ============================================================================= + + +class TestBulkExportYAMLView: + """BulkExportYAMLView returns YAML for selected PKs.""" + + def _make_request(self, pk_list): + request = MagicMock() + request.POST = MagicMock() + request.POST.getlist = MagicMock(return_value=pk_list) + return request + + def test_returns_yaml_content_type(self): + """Response has content-type text/yaml.""" + + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["1", "2"]) + + mock_mapping = MagicMock() + mock_mapping.to_yaml.return_value = "librenms_hardware: Cisco 4321\n" + + mock_qs = MagicMock() + mock_qs.filter.return_value.order_by.return_value = [mock_mapping, mock_mapping] + view.queryset = mock_qs + + with patch.object(view, "require_object_permissions", return_value=None): + response = view.post(request) + + assert "text/yaml" in response.get("Content-Type", "") + + def test_returns_yaml_for_selected_pks(self): + """Response body contains YAML from selected objects.""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["1"]) + + mock_mapping = MagicMock() + mock_mapping.to_yaml.return_value = "librenms_hardware: Cisco 4321\n" + + mock_qs = MagicMock() + mock_qs.filter.return_value.order_by.return_value = [mock_mapping] + view.queryset = mock_qs + + with patch.object(view, "require_object_permissions", return_value=None): + response = view.post(request) + + content = response.content.decode() + assert "Cisco 4321" in content + + def test_filters_by_selected_pks(self): + """View filters queryset by the selected PKs from POST data.""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request(["3", "7"]) + + mock_qs = MagicMock() + view.queryset = mock_qs + + with patch.object(view, "require_object_permissions", return_value=None): + view.post(request) + + mock_qs.filter.assert_called_once_with(pk__in=[3, 7]) + mock_qs.filter.return_value.order_by.assert_called_once_with("pk") + + def test_returns_200_with_empty_selection(self): + """Response is 400 when no PKs are selected.""" + from netbox_librenms_plugin.views.mapping_views import DeviceTypeMappingBulkExportYAMLView + + view = DeviceTypeMappingBulkExportYAMLView.__new__(DeviceTypeMappingBulkExportYAMLView) + request = self._make_request([]) + + mock_qs = MagicMock() + mock_qs.filter.return_value.order_by.return_value = [] + view.queryset = mock_qs + + with patch.object(view, "require_object_permissions", return_value=None): + response = view.post(request) + + assert response.status_code == 400 + + def test_all_mapping_bulk_export_yaml_views_exist(self): + """All mapping model BulkExportYAML views exist.""" + from netbox_librenms_plugin.views.mapping_views import ( + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + PlatformMappingBulkExportYAMLView, + ) + + for cls in [ + DeviceTypeMappingBulkExportYAMLView, + InterfaceTypeMappingBulkExportYAMLView, + InventoryIgnoreRuleBulkExportYAMLView, + ModuleBayMappingBulkExportYAMLView, + ModuleTypeMappingBulkExportYAMLView, + NormalizationRuleBulkExportYAMLView, + PlatformMappingBulkExportYAMLView, + ]: + assert cls is not None + + +# ============================================================================= +# TestPlatformMappingViewsExist +# ============================================================================= + + +class TestPlatformMappingViewsExist: + """All PlatformMapping CRUD views must exist in mapping_views.""" + + def test_list_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingListView + + assert PlatformMappingListView is not None + + def test_create_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingCreateView + + assert PlatformMappingCreateView is not None + + def test_edit_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingEditView + + assert PlatformMappingEditView is not None + + def test_delete_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingDeleteView + + assert PlatformMappingDeleteView is not None + + def test_bulk_delete_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingBulkDeleteView + + assert PlatformMappingBulkDeleteView is not None + + def test_bulk_import_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingBulkImportView + + assert PlatformMappingBulkImportView is not None + + def test_detail_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingView + + assert PlatformMappingView is not None + + def test_changelog_view_exists(self): + from netbox_librenms_plugin.views.mapping_views import PlatformMappingChangeLogView + + assert PlatformMappingChangeLogView is not None + + +# ============================================================================= +# TestPlatformMappingFormsExist +# ============================================================================= + + +class TestPlatformMappingFormsExist: + """PlatformMapping form classes must exist.""" + + def test_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingForm + + assert PlatformMappingForm is not None + + def test_filter_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingFilterForm + + assert PlatformMappingFilterForm is not None + + def test_import_form_exists(self): + from netbox_librenms_plugin.forms import PlatformMappingImportForm + + assert PlatformMappingImportForm is not None + + def test_filter_form_has_librenms_os_field(self): + """PlatformMappingFilterForm has librenms_os as a filter field.""" + from netbox_librenms_plugin.forms import PlatformMappingFilterForm + + assert "librenms_os" in PlatformMappingFilterForm.base_fields + + def test_import_form_fields_cover_required_columns(self): + """PlatformMappingImportForm covers librenms_os and netbox_platform.""" + from netbox_librenms_plugin.forms import PlatformMappingImportForm + + assert "librenms_os" in PlatformMappingImportForm._meta.fields + assert "netbox_platform" in PlatformMappingImportForm._meta.fields + + +# ============================================================================= +# TestReplacementTemplateValidation (Issue #64) +# ============================================================================= + + +class TestReplacementTemplateValidation: + """Replacement template is validated against a guaranteed-match synthetic pattern.""" + + # --- ModuleBayMapping --- + + def test_module_bay_mapping_rejects_out_of_range_backref(self): + """Referencing group \\2 when pattern has only one group should raise ValidationError.""" + import pytest + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = r"^(\d+)$" + mapping.librenms_class = "" + mapping.netbox_bay_name = r"Slot \2" # only 1 group + mapping.is_regex = True + mapping.description = "" + + with patch("netbox.models.NetBoxModel.clean"): + with pytest.raises(ValidationError) as exc_info: + mapping.clean() + assert "netbox_bay_name" in exc_info.value.message_dict + + def test_module_bay_mapping_accepts_valid_backref(self): + """Valid \\1 back-reference on a single-group pattern is accepted.""" + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = r"^(\d+)$" + mapping.librenms_class = "" + mapping.netbox_bay_name = r"Slot \1" + mapping.is_regex = True + mapping.description = "" + + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() # Should not raise + + def test_module_bay_mapping_accepts_named_backref(self): + """Valid \\g back-reference on a named-group pattern is accepted.""" + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = r"^(?P\d+)$" + mapping.librenms_class = "" + mapping.netbox_bay_name = r"Slot \g" + mapping.is_regex = True + mapping.description = "" + + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() # Should not raise + + def test_module_bay_mapping_rejects_invalid_named_backref(self): + """Referencing a named group that does not exist raises ValidationError.""" + import pytest + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import ModuleBayMapping + + mapping = ModuleBayMapping.__new__(ModuleBayMapping) + mapping.librenms_name = r"^(\d+)$" + mapping.librenms_class = "" + mapping.netbox_bay_name = r"Slot \g" + mapping.is_regex = True + mapping.description = "" + + with patch("netbox.models.NetBoxModel.clean"): + with pytest.raises(ValidationError) as exc_info: + mapping.clean() + assert "netbox_bay_name" in exc_info.value.message_dict + + # --- NormalizationRule --- + + def test_normalization_rule_rejects_out_of_range_backref(self): + """Replacement referencing group \\2 when pattern has only 1 group raises ValidationError.""" + import pytest + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.match_pattern = r"^(\w+)$" + rule.replacement = r"\2" # only 1 group + rule.scope = "device_type" + rule.description = "" + _set_fk_cache(rule, "manufacturer", None) + + with patch("netbox.models.NetBoxModel.clean"): + with pytest.raises(ValidationError) as exc_info: + rule.clean() + assert "replacement" in exc_info.value.message_dict + + def test_normalization_rule_accepts_valid_backref(self): + """Replacement \\1 on single-group pattern is accepted.""" + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.match_pattern = r"^(\w+)$" + rule.replacement = r"\1" + rule.scope = "device_type" + rule.description = "" + _set_fk_cache(rule, "manufacturer", None) + + with patch("netbox.models.NetBoxModel.clean"): + rule.clean() # Should not raise + + def test_normalization_rule_accepts_named_backref(self): + """Replacement \\g on named-group pattern is accepted.""" + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.match_pattern = r"^(?P\w+)$" + rule.replacement = r"\g" + rule.scope = "device_type" + rule.description = "" + _set_fk_cache(rule, "manufacturer", None) + + with patch("netbox.models.NetBoxModel.clean"): + rule.clean() # Should not raise + + def test_normalization_rule_empty_replacement_no_backref(self): + """Empty replacement string is valid for a pattern with capture groups.""" + from netbox_librenms_plugin.models import NormalizationRule + + rule = NormalizationRule.__new__(NormalizationRule) + rule.match_pattern = r"^(\d+)$" + rule.replacement = "" + rule.scope = "device_type" + rule.description = "" + _set_fk_cache(rule, "manufacturer", None) + + with patch("netbox.models.NetBoxModel.clean"): + rule.clean() # Should not raise diff --git a/netbox_librenms_plugin/tests/test_sync_devices.py b/netbox_librenms_plugin/tests/test_sync_devices.py index fb2e21228a..154b4158c0 100644 --- a/netbox_librenms_plugin/tests/test_sync_devices.py +++ b/netbox_librenms_plugin/tests/test_sync_devices.py @@ -121,8 +121,7 @@ class TestUpdateDeviceLocationView: """UpdateDeviceLocationView.post calls update_device_field with site name.""" def test_calls_update_device_field_with_site(self): - """ - post() resolves the NetBox site name and passes the exact API payload + """post() resolves the NetBox site name and passes the exact API payload expected by LibreNMS's PATCH /api/v0/devices/{id}/field endpoint: ``{"field": ["location", "override_sysLocation"], "data": [name, "1"]}``. """ @@ -171,6 +170,23 @@ def test_warning_when_no_site(self): mock_msg.warning.assert_called_once() +class TestAddDeviceObjectResolution: + """Regression tests for AddDeviceToLibreNMSView.get_object().""" + + def test_get_object_uses_get_object_or_404_for_virtualmachine(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + from virtualization.models import VirtualMachine + + view = object.__new__(AddDeviceToLibreNMSView) + vm_obj = MagicMock() + + with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=vm_obj) as mock_get_obj: + result = view.get_object(123, object_type="virtualmachine") + + assert result is vm_obj + mock_get_obj.assert_called_once_with(VirtualMachine, pk=123) + + class TestUpdateDeviceNameViewWiring: def test_has_all_required_mixins(self): from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView diff --git a/netbox_librenms_plugin/tests/test_sync_modules.py b/netbox_librenms_plugin/tests/test_sync_modules.py new file mode 100644 index 0000000000..47aae35df9 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_sync_modules.py @@ -0,0 +1,3301 @@ +""" +Tests for module sync views and BaseModuleTableView bay matching logic. + +Covers: InstallModuleView/InstallBranchView wiring, branch collection, cycle guards, +bay matching by name/mapping/position, serial comparison, status determination, +and depth tracking. inventory-rebased branch only. +""" + +import re +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + + +@contextmanager +def _patch_build_row_deps(view, match_bay_return=None): + """Patch all utility imports used by _build_row to isolate bay/type matching tests.""" + _utils = "netbox_librenms_plugin.utils" + with ( + patch.object(view, "_match_module_bay", return_value=match_bay_return), + patch(f"{_utils}.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)), + patch(f"{_utils}.has_nested_name_conflict", return_value=False), + ): + yield + + +def _make_install_branch_view(): + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = object.__new__(InstallBranchView) + view._librenms_api = None + return view + + +class TestInstallBranchViewCollectBranch: + """_collect_branch correctly collects parent + children depth-first.""" + + def _make_inventory(self, items): + """Helper to build a list of inventory dicts.""" + return items + + def test_collect_parent_with_model(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "WS-C4500X", "entPhysicalContainedIn": 0}, + ] + result = view._collect_branch(1, inventory) + assert len(result) == 1 + assert result[0]["entPhysicalIndex"] == 1 + + def test_collect_parent_without_model_excluded(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "", "entPhysicalContainedIn": 0}, + ] + result = view._collect_branch(1, inventory) + assert result == [] + + def test_collect_children_included_with_models(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "CHILD-A", "entPhysicalContainedIn": 1}, + {"entPhysicalIndex": 3, "entPhysicalModelName": "CHILD-B", "entPhysicalContainedIn": 1}, + ] + result = view._collect_branch(1, inventory) + indices = [item["entPhysicalIndex"] for item in result] + assert 1 in indices + assert 2 in indices + assert 3 in indices + + def test_parent_comes_before_children(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "CHILD", "entPhysicalContainedIn": 1}, + ] + result = view._collect_branch(1, inventory) + indices = [item["entPhysicalIndex"] for item in result] + assert indices.index(1) < indices.index(2) + + def test_deep_nesting_collected(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "ROOT", "entPhysicalContainedIn": 0}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "MID", "entPhysicalContainedIn": 1}, + {"entPhysicalIndex": 3, "entPhysicalModelName": "LEAF", "entPhysicalContainedIn": 2}, + ] + result = view._collect_branch(1, inventory) + assert len(result) == 3 + + def test_unknown_parent_returns_empty(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "ITEM", "entPhysicalContainedIn": 0}, + ] + result = view._collect_branch(999, inventory) + assert result == [] + + +class TestInstallBranchViewCollectChildrenCycleGuard: + """_collect_children must not loop on cyclic entPhysicalContainedIn links.""" + + def test_cycle_does_not_cause_infinite_recursion(self): + view = _make_install_branch_view() + # A ↔ B cycle (A contains B, B contains A) + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "A", "entPhysicalContainedIn": 2}, + {"entPhysicalIndex": 2, "entPhysicalModelName": "B", "entPhysicalContainedIn": 1}, + ] + items = [] + # Should terminate without RecursionError + view._collect_children(1, inventory, items, visited={1}) + + def test_self_reference_does_not_loop(self): + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 5, "entPhysicalModelName": "SELF", "entPhysicalContainedIn": 5}, + ] + items = [] + view._collect_children(5, inventory, items, visited={5}) + # No infinite recursion β€” length may be 0 (self is excluded by visited) + assert len(items) == 0 + + +class TestGetModuleTypesIndexed: + """get_module_types_indexed builds a dict keyed by model name, part number, and mappings.""" + + def test_indexes_by_model_and_part_number(self): + from netbox_librenms_plugin.utils import get_module_types_indexed + + mt1 = MagicMock() + mt1.model = "WS-X4748" + mt1.part_number = "ALT-PART-4748" + + mt2 = MagicMock() + mt2.model = "WS-X4516" + mt2.part_number = "WS-X4516" # same as model β†’ no extra key + + mock_mapping = MagicMock() + mock_mapping.librenms_model = "libre-model-a" + mock_mapping.netbox_module_type = mt1 + mock_mapping.manufacturer_id = None + + mock_mt_cls = MagicMock() + mock_mt_cls.objects.all.return_value.select_related.return_value.prefetch_related.return_value = [mt1, mt2] + + mock_map_cls = MagicMock() + mock_map_cls.objects.select_related.return_value.prefetch_related.return_value = [mock_mapping] + + with patch.dict( + "sys.modules", + { + "dcim.models": type("m", (), {"ModuleType": mock_mt_cls})(), + }, + ): + with patch("netbox_librenms_plugin.models.ModuleTypeMapping", mock_map_cls): + result = get_module_types_indexed() + + assert result["WS-X4748"] is mt1 + assert result["ALT-PART-4748"] is mt1 + assert result["WS-X4516"] is mt2 + assert result["libre-model-a"] is mt1 + + def test_mapping_overrides_ambiguous_base_key(self): + """A unique ModuleTypeMapping entry overrides a base key that became ambiguous.""" + from netbox_librenms_plugin.utils import get_module_types_indexed + + # Two ModuleTypes share the same model name β†’ base key becomes ambiguous + mt1 = MagicMock() + mt1.model = "SFP-1G-LX" + mt1.part_number = "" + + mt2 = MagicMock() + mt2.model = "SFP-1G-LX" # same β†’ ambiguous in base pass + mt2.part_number = "" + + # Explicit mapping for a distinct key β†’ should be present despite base ambiguity + mock_mapping = MagicMock() + mock_mapping.librenms_model = "SFP-1G-LX-EXPLICIT" + mock_mapping.netbox_module_type = mt1 + mock_mapping.manufacturer_id = None + + mock_mt_cls = MagicMock() + mock_mt_cls.objects.all.return_value.select_related.return_value.prefetch_related.return_value = [mt1, mt2] + + mock_map_cls = MagicMock() + mock_map_cls.objects.select_related.return_value.prefetch_related.return_value = [mock_mapping] + + with patch.dict( + "sys.modules", + { + "dcim.models": type("m", (), {"ModuleType": mock_mt_cls})(), + }, + ): + with patch("netbox_librenms_plugin.models.ModuleTypeMapping", mock_map_cls): + result = get_module_types_indexed() + + # Base key was ambiguous and must be absent; explicit mapping key must be present + assert "SFP-1G-LX" not in result + assert result["SFP-1G-LX-EXPLICIT"] is mt1 + + def test_manufacturer_scoped_mapping_kept_separate_from_base_index(self): + """A manufacturer-scoped ModuleTypeMapping must not pollute the global key space.""" + from netbox_librenms_plugin.utils import get_module_types_indexed + + mt_juniper = MagicMock() + mt_juniper.model = "QSFP-100G-LR4-JUNIPER" + mt_juniper.part_number = "" + + mock_mapping = MagicMock() + mock_mapping.librenms_model = "1F3QAA" + mock_mapping.netbox_module_type = mt_juniper + mock_mapping.manufacturer_id = 42 # scoped β†’ must NOT enter the global dict + + mock_mt_cls = MagicMock() + mock_mt_cls.objects.all.return_value.select_related.return_value.prefetch_related.return_value = [mt_juniper] + + mock_map_cls = MagicMock() + mock_map_cls.objects.select_related.return_value.prefetch_related.return_value = [mock_mapping] + + with patch.dict("sys.modules", {"dcim.models": type("m", (), {"ModuleType": mock_mt_cls})()}): + with patch("netbox_librenms_plugin.models.ModuleTypeMapping", mock_map_cls): + result = get_module_types_indexed() + + # The vendor-scoped librenms_model must NOT leak into the global lookup + assert "1F3QAA" not in result + # …but it must be reachable via the per-manufacturer side index + assert result.mfr_mappings[(42, "1F3QAA")] is mt_juniper + + +class TestResolveModuleTypeManufacturerScope: + """resolve_module_type must prefer manufacturer-scoped mappings when available.""" + + def _index(self, base=None, mfr=None): + from netbox_librenms_plugin.utils import _ModuleTypeIndex + + return _ModuleTypeIndex(base or {}, mfr_mappings=mfr or {}) + + def test_manufacturer_scoped_wins_over_global(self): + from netbox_librenms_plugin.utils import resolve_module_type + + global_mt = MagicMock(name="global_mt") + scoped_mt = MagicMock(name="scoped_mt") + idx = self._index(base={"X": global_mt}, mfr={(7, "X"): scoped_mt}) + manufacturer = MagicMock(pk=7) + + assert resolve_module_type("X", idx, manufacturer=manufacturer) is scoped_mt + + def test_falls_back_to_global_when_no_scoped_match(self): + from netbox_librenms_plugin.utils import resolve_module_type + + global_mt = MagicMock(name="global_mt") + idx = self._index(base={"X": global_mt}, mfr={(7, "OTHER"): MagicMock()}) + manufacturer = MagicMock(pk=7) + + assert resolve_module_type("X", idx, manufacturer=manufacturer) is global_mt + + def test_other_vendor_does_not_see_scoped_mapping(self): + from netbox_librenms_plugin.utils import resolve_module_type + + scoped_mt = MagicMock(name="scoped_mt") + idx = self._index(base={}, mfr={(7, "X"): scoped_mt}) + other_mfr = MagicMock(pk=99) + + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda v, *a, **k: v): + assert resolve_module_type("X", idx, manufacturer=other_mfr) is None + + def test_no_manufacturer_falls_back_to_global_only(self): + from netbox_librenms_plugin.utils import resolve_module_type + + scoped_mt = MagicMock(name="scoped_mt") + idx = self._index(base={}, mfr={(7, "X"): scoped_mt}) + + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda v, *a, **k: v): + assert resolve_module_type("X", idx, manufacturer=None) is None + + +class TestInstallModuleViewWiring: + """InstallModuleView must have correct mixins and attributes.""" + + def test_has_librenms_permission_mixin(self): + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + assert LibreNMSPermissionMixin in InstallModuleView.__mro__ + + def test_has_netbox_object_permission_mixin(self): + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + assert NetBoxObjectPermissionMixin in InstallModuleView.__mro__ + + def test_install_module_view_not_in_base(self): + """InstallModuleView is importable from the public sync module, not views/base.""" + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + + assert callable(InstallModuleView), "InstallModuleView must be a callable class" + assert InstallModuleView.__module__ == "netbox_librenms_plugin.views.sync.modules", ( + "InstallModuleView must be defined in views/sync/modules.py, not views/base/" + ) + + +class TestInstallBranchViewWiring: + """InstallBranchView must have CacheMixin for cache key generation.""" + + def test_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + from netbox_librenms_plugin.views.mixins import CacheMixin + + assert CacheMixin in InstallBranchView.__mro__ + + def test_has_netbox_object_permission_mixin(self): + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + assert NetBoxObjectPermissionMixin in InstallBranchView.__mro__ + + +# --------------------------------------------------------------------------- +# Helper: build a BaseModuleTableView instance without __init__ +# --------------------------------------------------------------------------- + + +def _make_base_view(): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + return view + + +_bay_counter = 0 + + +def _bay(name, installed_module=None, pk=None): + """Quick MagicMock module bay.""" + global _bay_counter + _bay_counter += 1 + bay = MagicMock() + bay.name = name + bay.pk = pk or _bay_counter + bay.installed_module = installed_module + bay.get_absolute_url.return_value = f"/dcim/module-bays/{bay.pk}/" + return bay + + +def _module(serial="SN001", module_type_id=1): + mod = MagicMock() + mod.serial = serial + mod.module_type_id = module_type_id + mod.get_absolute_url.return_value = "/dcim/modules/1/" + return mod + + +# --------------------------------------------------------------------------- +# _determine_status +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Serial comparison inside _build_row +# --------------------------------------------------------------------------- + + +class TestBuildRowSerialComparison: + """_build_row sets 'Installed' or 'Serial Mismatch' based on installed module serial.""" + + def _make_item(self, model_name, serial): + return { + "entPhysicalModelName": model_name, + "entPhysicalSerialNum": serial, + "entPhysicalName": model_name, + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalIndex": 10, + "entPhysicalContainedIn": 0, + } + + def _make_matched_type(self, model="WS-X4748"): + mt = MagicMock() + mt.model = model + mt.pk = 1 + mt.get_absolute_url.return_value = "/dcim/module-types/1/" + # Make uses-module-path/token checks return False so badges don't appear + mt.interfacetemplates = MagicMock() + mt.interfacetemplates.all.return_value = [] + return mt + + def test_matching_serial_gives_installed_status(self): + view = _make_base_view() + item = self._make_item("WS-X4748", "SN-ABC-123") + mt = self._make_matched_type() + installed = _module(serial="SN-ABC-123") + bay = _bay("Slot 1", installed_module=installed) + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["status"] == "Installed" + assert "row_class" not in row + + def test_serial_mismatch_gives_danger_status(self): + view = _make_base_view() + item = self._make_item("WS-X4748", "SN-NEW-999") + mt = self._make_matched_type() + installed = _module(serial="SN-OLD-111") + bay = _bay("Slot 1", installed_module=installed) + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["status"] == "Serial Mismatch" + assert "row_class" not in row + + def test_no_bay_gives_no_bay_status(self): + view = _make_base_view() + item = self._make_item("WS-X4748", "SN1") + mt = self._make_matched_type() + + with _patch_build_row_deps(view, match_bay_return=None): + row = view._build_row(item, {10: item}, {}, {"WS-X4748": mt}, depth=0) + + assert row["status"] == "No Bay" + + def test_no_type_gives_no_type_status(self): + view = _make_base_view() + item = self._make_item("UNKNOWN-MODEL", "SN1") + bay = _bay("Slot 1") + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {}, depth=0) + + assert row["status"] == "No Type" + + def test_can_install_set_when_bay_free_and_type_matched(self): + """can_install=True only when bay exists, type matched, and bay is empty.""" + view = _make_base_view() + item = self._make_item("WS-X4748", "SN1") + mt = self._make_matched_type() + bay = _bay("Slot 1", installed_module=None) + bay.installed_module = None + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["can_install"] is True + + def test_can_install_false_when_bay_occupied(self): + view = _make_base_view() + item = self._make_item("WS-X4748", "SN1") + mt = self._make_matched_type() + installed = _module(serial="SN1") + bay = _bay("Slot 1", installed_module=installed) + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["can_install"] is False + + def test_librenms_dash_serial_with_empty_installed_gives_installed(self): + """LibreNMS serial '-' normalizes to empty; both empty -> Installed, not mismatch.""" + view = _make_base_view() + item = self._make_item("WS-X4748", "-") + mt = self._make_matched_type() + installed = _module(serial="") + bay = _bay("Slot 1", installed_module=installed) + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["status"] == "Installed" + assert "row_class" not in row + + def test_librenms_dash_serial_with_real_installed_gives_installed(self): + """LibreNMS serial '-' normalizes to empty; only NetBox has serial -> no mismatch.""" + view = _make_base_view() + item = self._make_item("WS-X4748", "-") + mt = self._make_matched_type() + installed = _module(serial="REAL123") + bay = _bay("Slot 1", installed_module=installed) + + with _patch_build_row_deps(view, match_bay_return=bay): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["status"] == "Installed" + assert "row_class" not in row + + +# --------------------------------------------------------------------------- +# Depth tracking in render_name +# --------------------------------------------------------------------------- + + +class TestRenderNameDepth: + """render_name applies tree indentation based on depth.""" + + def test_depth_zero_returns_plain_value(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable([]) + result = table.render_name("Supervisor", {"depth": 0}) + assert "padding-left" not in str(result) + assert "Supervisor" in str(result) + + def test_depth_one_adds_padding(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable([]) + result = str(table.render_name("Line Card", {"depth": 1})) + assert "padding-left" in result + assert "20px" in result + + def test_depth_two_doubles_padding(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable([]) + result = str(table.render_name("SFP", {"depth": 2})) + assert "40px" in result + + def test_depth_renders_tree_prefix(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable([]) + result = str(table.render_name("Port 1", {"depth": 1})) + assert "└─" in result + + +# --------------------------------------------------------------------------- +# _match_bay_by_position +# --------------------------------------------------------------------------- + + +class TestMatchBayByPosition: + """_match_bay_by_position resolves position-based bay names for SFPs in converters.""" + + def test_matches_sfp_slot_by_sibling_order(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + # Build an inventory: parent (model) β†’ container1 β†’ item1, container2 β†’ item2 + parent_item = { + "entPhysicalIndex": 1, + "entPhysicalModelName": "CONVERTER", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + } + container1 = { + "entPhysicalIndex": 2, + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 1, + } + container2 = { + "entPhysicalIndex": 3, + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 2, + } + sfp1 = { + "entPhysicalIndex": 4, + "entPhysicalModelName": "SFP-10G-LR", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 2, + "entPhysicalParentRelPos": 1, + } + sfp2 = { + "entPhysicalIndex": 5, + "entPhysicalModelName": "SFP-10G-SR", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 3, + "entPhysicalParentRelPos": 1, + } + + index_map = {1: parent_item, 2: container1, 3: container2, 4: sfp1, 5: sfp2} + bays = {"SFP 1": _bay("SFP 1"), "SFP 2": _bay("SFP 2")} + + result1 = BaseModuleTableView._match_bay_by_position(sfp1, index_map, bays) + result2 = BaseModuleTableView._match_bay_by_position(sfp2, index_map, bays) + + assert result1 is bays["SFP 1"] + assert result2 is bays["SFP 2"] + + def test_returns_none_when_no_modelless_container(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + # Item directly under parent with model (no modelless container) + parent = {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0} + item = {"entPhysicalIndex": 2, "entPhysicalModelName": "CHILD", "entPhysicalContainedIn": 1} + index_map = {1: parent, 2: item} + bays = {"Slot 1": _bay("Slot 1")} + + result = BaseModuleTableView._match_bay_by_position(item, index_map, bays) + assert result is None + + def test_returns_none_when_no_bays_match_pattern(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + parent = { + "entPhysicalIndex": 1, + "entPhysicalModelName": "M", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + } + container = { + "entPhysicalIndex": 2, + "entPhysicalModelName": "", + "entPhysicalClass": "container", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 1, + } + item = { + "entPhysicalIndex": 3, + "entPhysicalModelName": "X", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 2, + "entPhysicalParentRelPos": 1, + } + index_map = {1: parent, 2: container, 3: item} + bays = {"InterfaceA": _bay("InterfaceA")} # no "SFP 1"/"Slot 1"/etc. + + result = BaseModuleTableView._match_bay_by_position(item, index_map, bays) + assert result is None + + +# --------------------------------------------------------------------------- +# _match_module_bay β€” exact name fallback +# --------------------------------------------------------------------------- + + +class TestMatchModuleBayExactFallback: + """When no ModuleBayMapping exists, exact parent/item/descr name is tried.""" + + def test_exact_parent_name_match(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = _make_base_view() + parent = { + "entPhysicalIndex": 1, + "entPhysicalModelName": "PARENT", + "entPhysicalContainedIn": 0, + "entPhysicalName": "Slot 1", + } + item = { + "entPhysicalIndex": 2, + "entPhysicalName": "Linecard A", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 1, + } + index_map = {1: parent, 2: item} + bay = _bay("Slot 1") + bays = {"Slot 1": bay} + + with patch( + "netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda name, scope, **kw: name + ): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mbm: + mock_mbm.objects.filter.return_value.first.return_value = None + + # Also patch _lookup_regex_bay_mapping to return None + with patch.object(BaseModuleTableView, "_lookup_regex_bay_mapping", return_value=None): + with patch.object(BaseModuleTableView, "_match_bay_by_position", return_value=None): + result = view._match_module_bay(item, index_map, bays) + + assert result is bay + + def test_item_name_used_when_no_parent_name(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = _make_base_view() + item = { + "entPhysicalIndex": 1, + "entPhysicalName": "Module Bay 3", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + } + index_map = {1: item} + bay = _bay("Module Bay 3") + bays = {"Module Bay 3": bay} + + with patch( + "netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda name, scope, **kw: name + ): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mbm: + mock_mbm.objects.filter.return_value.first.return_value = None + with patch.object(BaseModuleTableView, "_lookup_regex_bay_mapping", return_value=None): + with patch.object(BaseModuleTableView, "_match_bay_by_position", return_value=None): + result = view._match_module_bay(item, index_map, bays) + + assert result is bay + + def test_returns_none_when_no_match(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = _make_base_view() + item = { + "entPhysicalIndex": 1, + "entPhysicalName": "Unknown-X", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + } + index_map = {1: item} + bays = {"Slot 1": _bay("Slot 1")} + + with patch( + "netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda name, scope, **kw: name + ): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mbm: + mock_mbm.objects.filter.return_value.first.return_value = None + with patch.object(BaseModuleTableView, "_lookup_regex_bay_mapping", return_value=None): + with patch.object(BaseModuleTableView, "_match_bay_by_position", return_value=None): + result = view._match_module_bay(item, index_map, bays) + + assert result is None + + +# --------------------------------------------------------------------------- +# _install_single β€” status codes +# --------------------------------------------------------------------------- + + +class TestInstallSingleStatus: + """_install_single returns the correct status dict in each path.""" + + def _make_args(self): + """Return (device, item, index_map, module_types, ModuleBay, ModuleType, Module).""" + device = MagicMock() + device.device_type.manufacturer = None + + item = { + "entPhysicalIndex": 10, + "entPhysicalModelName": "WS-X4748", + "entPhysicalSerialNum": "SN123", + "entPhysicalName": "Line Card", + "entPhysicalContainedIn": 0, + } + + mt = MagicMock() + mt.model = "WS-X4748" + mt.pk = 1 + + bay = _bay("Slot 1") + bay.installed_module = None + + index_map = {10: item} + module_types = {"WS-X4748": mt} + + ModuleBay = MagicMock() + ModuleBay.objects.filter.return_value.select_related.return_value = [bay] + # Support select_for_update chain used in _install_single + locked_bay = _bay("Slot 1") + locked_bay.installed_module = None + locked_bay.pk = bay.pk + ModuleBay.objects.select_for_update.return_value.select_related.return_value.get.return_value = locked_bay + ModuleType = MagicMock() + Module = MagicMock() + + return device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt + + def test_returns_installed_on_success(self): + from contextlib import contextmanager + + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + module_instance = MagicMock() + Module.return_value = module_instance + + @contextmanager + def noop_atomic(): + yield + + with patch("netbox_librenms_plugin.views.sync.modules.transaction.atomic", noop_atomic): + with patch("netbox_librenms_plugin.utils.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)): + with patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])): + with patch.object(InstallBranchView, "_find_parent_module_id", return_value=None): + with patch.object(InstallBranchView, "_match_bay", return_value=bay): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "installed" + assert "WS-X4748" in result["name"] + + def test_returns_skipped_when_no_type(self): + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + + with patch("netbox_librenms_plugin.utils.resolve_module_type", return_value=None): + result = view._install_single( + device, + item, + index_map, + {}, # empty module_types β†’ no match + ModuleBay, + ModuleType, + Module, + ) + + assert result["status"] == "skipped" + assert "no matching type" in result["reason"] + + def test_returns_skipped_when_no_bay(self): + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + + with patch("netbox_librenms_plugin.utils.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)): + with patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])): + with patch.object(InstallBranchView, "_find_parent_module_id", return_value=None): + with patch.object(InstallBranchView, "_match_bay", return_value=None): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "skipped" + assert "no matching bay" in result["reason"] + + def test_returns_skipped_when_bay_already_occupied(self): + from contextlib import contextmanager + + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + occupied_module = _module() + bay.installed_module = occupied_module + # Update the locked bay to also appear occupied + locked_bay = MagicMock() + locked_bay.installed_module = occupied_module + locked_bay.pk = bay.pk + ModuleBay.objects.select_for_update.return_value.select_related.return_value.get.return_value = locked_bay + + @contextmanager + def noop_atomic(): + yield + + with patch("netbox_librenms_plugin.views.sync.modules.transaction.atomic", noop_atomic): + with patch("netbox_librenms_plugin.utils.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)): + with patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])): + with patch.object(InstallBranchView, "_find_parent_module_id", return_value=None): + with patch.object(InstallBranchView, "_match_bay", return_value=bay): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "skipped" + assert "already occupied" in result["reason"] + + def test_returns_failed_on_exception(self): + from contextlib import contextmanager + + from django.db import IntegrityError + + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + Module.side_effect = IntegrityError("DB error") + + @contextmanager + def noop_atomic(): + yield + + with patch("netbox_librenms_plugin.views.sync.modules.transaction.atomic", noop_atomic): + with patch("netbox_librenms_plugin.utils.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)): + with patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])): + with patch.object(InstallBranchView, "_find_parent_module_id", return_value=None): + with patch.object(InstallBranchView, "_match_bay", return_value=bay): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "failed" + + def test_dash_serial_normalized_to_empty_on_install(self): + """When LibreNMS reports serial '-', _install_single normalizes it to '' before Module().""" + from contextlib import contextmanager + + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + # Override the serial to "-" + item["entPhysicalSerialNum"] = "-" + module_instance = MagicMock() + Module.return_value = module_instance + + @contextmanager + def noop_atomic(): + yield + + with patch("netbox_librenms_plugin.views.sync.modules.transaction.atomic", noop_atomic): + with patch("netbox_librenms_plugin.utils.resolve_module_type", side_effect=lambda m, t, **kw: t.get(m)): + with patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])): + with patch.object(InstallBranchView, "_find_parent_module_id", return_value=None): + with patch.object(InstallBranchView, "_match_bay", return_value=bay): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "installed" + # Verify Module was constructed with serial="" (not "-") + Module.assert_called_once() + assert Module.call_args.kwargs["serial"] == "", ( + f"Expected serial='' but Module was called with serial={Module.call_args.kwargs['serial']!r}" + ) + + +# --------------------------------------------------------------------------- +# Regression: ToggleColumn accessor for per-row checkboxes +# --------------------------------------------------------------------------- + + +class TestToggleColumnAccessor: + """ToggleColumn must have accessor='ent_physical_index' so per-row checkboxes render.""" + + def test_selection_column_has_correct_accessor(self): + """Regression: without accessor='ent_physical_index' checkboxes are empty.""" + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + col = LibreNMSModuleTable.base_columns["selection"] + assert col.accessor == "ent_physical_index", ( + "ToggleColumn must use accessor='ent_physical_index'; " + "otherwise the column value resolves to '' and render() is never called" + ) + + def test_selection_column_renders_checkbox_for_record_with_index(self): + """Per-row checkbox renders when ent_physical_index is present in record.""" + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + record = { + "ent_physical_index": 42, + "name": "Slot 1", + "model": "WS-X4748", + "depth": 0, + } + table = LibreNMSModuleTable([record]) + rows = list(table.rows) + assert len(rows) == 1 + # The cell value should render a checkbox HTML input element + cell_val = rows[0].get_cell("selection") + assert "> any table_data list length + CHILD_IDX = 8_000_001 + + inventory = [ + { + "entPhysicalIndex": LARGE_IDX, + "entPhysicalClass": "module", + "entPhysicalModelName": "BIG-MODULE", + "entPhysicalContainedIn": 0, + "entPhysicalSerialNum": "SN1", + "entPhysicalName": "Big Module", + }, + { + "entPhysicalIndex": CHILD_IDX, + "entPhysicalClass": "port", + "entPhysicalModelName": "SFP-X", + "entPhysicalContainedIn": LARGE_IDX, + "entPhysicalSerialNum": "SN2", + "entPhysicalName": "Port 1", + }, + ] + + view = object.__new__(BaseModuleTableView) + view._device_manufacturer = None + view._librenms_api = MagicMock(server_key="test-server") + + captured_table_data = [] + + def fake_build_row( + item, + index_map, + bays, + module_types, + depth=0, + manufacturer=None, + sibling_counts=None, + scope_uninstalled=False, + scope_preserved=False, + scope_empty_installed_bays=False, + ): + if item.get("entPhysicalIndex") == LARGE_IDX: + return {"ent_physical_index": LARGE_IDX, "can_install": False, "depth": 0} + # child returns can_install=True to trigger the has_installable_children path + return {"ent_physical_index": CHILD_IDX, "can_install": True, "depth": 1} + + def fake_get_table(table_data, obj): + captured_table_data.extend(table_data) + return MagicMock() + + request = MagicMock() + obj = MagicMock() + obj.device_type.manufacturer = None + + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping: + mock_mapping.objects.all.return_value = [] + with patch("netbox_librenms_plugin.models.InventoryIgnoreRule") as mock_ignore: + mock_ignore.objects.filter.return_value.order_by.return_value = [] + with patch("netbox_librenms_plugin.utils.preload_normalization_rules", return_value={}): + with patch.object(view, "_get_module_bays", return_value=({}, {})): + with patch.object(view, "_get_module_types", return_value={}): + with patch.object(view, "_get_generic_module_types", return_value={}): + with patch.object(view, "_get_module_type_ambiguities", return_value={}): + with patch.object(view, "_get_carrier_install_rules", return_value=[]): + with patch.object(view, "_build_row", side_effect=fake_build_row): + with patch.object(view, "get_table", side_effect=fake_get_table): + with patch.object( + view, "_sort_with_hierarchy", side_effect=lambda x: x + ): + with patch( + "netbox_librenms_plugin.views.base.modules_view.cache" + ) as mock_cache: + mock_cache.ttl = lambda k: None + # Old bug: IndexError when large entity index used as list index + view._build_context(request, obj, inventory) + + assert len(captured_table_data) >= 1, "table_data must contain the parent row" + assert captured_table_data[0].get("has_installable_children") is True, ( + "has_installable_children must be set on table row 0 (parent_row_idx), " + "not at entity index 8_000_000 which would cause IndexError" + ) + + +# --------------------------------------------------------------------------- +# Regression: install views must NOT delete the LibreNMS inventory cache +# --------------------------------------------------------------------------- + + +class TestInstallViewsDoNotDeleteCache: + """ + Install views must not call cache.delete after a successful install. + + The LibreNMS inventory cache stores what LibreNMS reports (hardware list). + It is unaffected by NetBox module installs; _get_module_bays() is a live DB + query so the next render correctly shows the "Installed" state without any + cache invalidation. Deleting the cache after install caused an empty modules + tab (regression). + + Each test exercises the view's success path and asserts that cache.delete + was never called. + """ + + def test_install_module_view_no_cache_delete(self): + """InstallModuleView.post success path must not call cache.delete.""" + from contextlib import contextmanager + + from dcim.models import ModuleBay + + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + + view = object.__new__(InstallModuleView) + view.required_object_permissions = {} + device = _make_device() + + module_bay = MagicMock() + module_bay.name = "Slot 1" + module_bay.installed_module = None + + module_type = MagicMock() + module_type.pk = 5 + module_type.model = "XCM-7s" + + new_module = MagicMock() + request = _make_request("POST", data={"module_bay_id": "10", "module_type_id": "5", "serial": "SN1"}) + + @contextmanager + def noop_atomic(): + yield + + mock_qs = MagicMock() + mock_qs.get.return_value = module_bay + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, module_bay, module_type], + ), + 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_messages, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + patch("dcim.models.Module") as mock_module_cls, + patch.object(ModuleBay, "objects") as mock_objects, + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + ): + mock_tx.atomic = noop_atomic + mock_module_cls.return_value = new_module + mock_objects.select_for_update.return_value = mock_qs + view.post(request, pk=24) + + mock_messages.success.assert_called_once() + mock_cache.delete.assert_not_called() + + def test_install_branch_view_no_cache_delete(self): + """InstallBranchView.post success path must not call cache.delete.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = object.__new__(InstallBranchView) + view.required_object_permissions = {} + device = _make_device() + + request = _make_request("POST", data={"parent_index": "100", "server_key": "default"}) + + cached_inventory = [ + { + "entPhysicalIndex": 100, + "entPhysicalClass": "module", + "entPhysicalModelName": "MOD-A", + "entPhysicalContainedIn": 0, + "entPhysicalName": "Slot 0", + }, + ] + + install_result = {"status": "installed", "name": "Slot 0"} + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch.object(view, "get_cache_key", return_value="test-key"), + patch.object(InstallBranchView, "_collect_branch", return_value=cached_inventory), + patch.object(InstallBranchView, "_install_single", return_value=install_result), + patch("netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", return_value={}), + 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.views.sync.modules.transaction") as mock_tx, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + ): + mock_cache.get.return_value = {"inventory": cached_inventory, "librenms_id": "test"} + mock_tx.atomic = lambda *a, **kw: MagicMock(__enter__=MagicMock(), __exit__=MagicMock(return_value=False)) + view.post(request, pk=24) + + mock_messages.success.assert_called_once() + mock_cache.delete.assert_not_called() + + def test_install_selected_view_no_cache_delete(self): + """InstallSelectedView.post success path must not call cache.delete.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView, InstallSelectedView + + view = object.__new__(InstallSelectedView) + view.required_object_permissions = {} + device = _make_device() + + request = _make_request("POST", data={"server_key": "default"}) + post_mock = MagicMock() + post_mock.get = MagicMock(side_effect=lambda k, d=None: {"server_key": "default"}.get(k, d)) + post_mock.getlist = MagicMock(return_value=["100"]) + request.POST = post_mock + + cached_inventory = [ + { + "entPhysicalIndex": 100, + "entPhysicalClass": "module", + "entPhysicalModelName": "MOD-A", + "entPhysicalContainedIn": 0, + "entPhysicalName": "Slot 0", + }, + ] + + install_result = {"status": "installed", "name": "Slot 0"} + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.cache") as mock_cache, + patch.object(view, "get_cache_key", return_value="test-key"), + patch.object(InstallBranchView, "_install_single", return_value=install_result), + patch("netbox_librenms_plugin.views.sync.modules.get_module_types_indexed", return_value={}), + patch("netbox_librenms_plugin.utils.get_enabled_ignore_rules", return_value=[]), + patch("netbox_librenms_plugin.utils.load_bay_mappings", return_value=([], [])), + patch("netbox_librenms_plugin.views.sync.modules.transaction") as mock_tx, + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.modules.redirect"), + ): + mock_cache.get.return_value = {"inventory": cached_inventory, "librenms_id": "test"} + mock_tx.atomic = lambda *a, **kw: MagicMock(__enter__=MagicMock(), __exit__=MagicMock(return_value=False)) + view.post(request, pk=24) + + mock_messages.success.assert_called_once() + mock_cache.delete.assert_not_called() + + +# --------------------------------------------------------------------------- +# _collect_children with ignore rules (Item 4) +# --------------------------------------------------------------------------- + + +def _make_rule(pattern="IDPROM", action="skip"): + """Create a lightweight InventoryIgnoreRule-like object for testing.""" + from netbox_librenms_plugin.models import InventoryIgnoreRule + + rule = InventoryIgnoreRule.__new__(InventoryIgnoreRule) + rule.match_type = "ends_with" + rule.pattern = pattern + rule.action = action + rule.require_serial_match_parent = False + rule.enabled = True + return rule + + +class TestCollectChildrenIgnoreRules: + """_collect_children respects ignore rules when provided.""" + + def test_skip_rule_excludes_item_and_subtree(self): + """Child matching a 'skip' rule is excluded along with its descendants.""" + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + { + "entPhysicalIndex": 2, + "entPhysicalModelName": "SKIP-ME", + "entPhysicalName": "FT-IDPROM", + "entPhysicalContainedIn": 1, + }, + { + "entPhysicalIndex": 3, + "entPhysicalModelName": "DEEP-SKIP", + "entPhysicalName": "DEEP", + "entPhysicalContainedIn": 2, + }, + { + "entPhysicalIndex": 4, + "entPhysicalModelName": "KEEP-ME", + "entPhysicalName": "NormalChild", + "entPhysicalContainedIn": 1, + }, + ] + index_map = {i["entPhysicalIndex"]: i for i in inventory} + rule = _make_rule(pattern="IDPROM", action="skip") + items = [] + view._collect_children( + 1, inventory, items, visited={1}, ignore_rules=[rule], device_serial="", index_map=index_map + ) + indices = [i["entPhysicalIndex"] for i in items] + assert 2 not in indices # skip rule matched + assert 3 not in indices # descendant of skip-matched + assert 4 in indices # not matched + + def test_transparent_rule_collects_children_not_item(self): + """Child matching a 'transparent' rule is excluded but its children are collected.""" + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + { + "entPhysicalIndex": 2, + "entPhysicalModelName": "TRANSPARENT", + "entPhysicalName": "T-IDPROM", + "entPhysicalContainedIn": 1, + }, + { + "entPhysicalIndex": 3, + "entPhysicalModelName": "GRANDCHILD", + "entPhysicalName": "GrandChild", + "entPhysicalContainedIn": 2, + }, + ] + index_map = {i["entPhysicalIndex"]: i for i in inventory} + rule = _make_rule(pattern="IDPROM", action="transparent") + items = [] + view._collect_children( + 1, inventory, items, visited={1}, ignore_rules=[rule], device_serial="", index_map=index_map + ) + indices = [i["entPhysicalIndex"] for i in items] + assert 2 not in indices # transparent item excluded + assert 3 in indices # grandchild promoted + + def test_no_ignore_rules_includes_all(self): + """Passing ignore_rules=None preserves existing behaviour (all items collected).""" + view = _make_install_branch_view() + inventory = [ + {"entPhysicalIndex": 1, "entPhysicalModelName": "PARENT", "entPhysicalContainedIn": 0}, + { + "entPhysicalIndex": 2, + "entPhysicalModelName": "CHILD-IDPROM", + "entPhysicalName": "X-IDPROM", + "entPhysicalContainedIn": 1, + }, + ] + items = [] + view._collect_children(1, inventory, items, visited={1}) + assert any(i["entPhysicalIndex"] == 2 for i in items) + + +# --------------------------------------------------------------------------- +# _find_parent_module_id with regex mappings (Item 6) +# --------------------------------------------------------------------------- + + +class TestFindParentModuleIdRegex: + """_find_parent_module_id applies regex ModuleBayMappings in ancestor walk.""" + + def _make_bay(self, name, installed_module_id=None): + bay = MagicMock() + bay.name = name + if installed_module_id is not None: + bay.installed_module = MagicMock() + bay.installed_module.pk = installed_module_id + else: + bay.installed_module = None + return bay + + def _make_regex_mapping(self, pattern, netbox_bay_name): + m = MagicMock() + m.is_regex = True + m.librenms_name = pattern + m.netbox_bay_name = netbox_bay_name + m.librenms_class = "" + m._compiled_pattern = re.compile(pattern) + return m + + def _make_exact_mapping(self, librenms_name, netbox_bay_name): + m = MagicMock() + m.is_regex = False + m.librenms_name = librenms_name + m.netbox_bay_name = netbox_bay_name + m.librenms_class = "" + return m + + def test_regex_mapping_matches_ancestor_name(self): + """A regex mapping on an ancestor name resolves to the installed module id.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "Slot 3/0", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + child = {"entPhysicalIndex": 20, "entPhysicalModelName": "SFP-X", "entPhysicalContainedIn": 10} + index_map = {10: parent, 20: child} + + bay = self._make_bay("Slot3", installed_module_id=77) + regex_mapping = self._make_regex_mapping(r"Slot \d+/\d+", "Slot3") + device_bays = [bay] + + result = InstallBranchView._find_parent_module_id(child, index_map, device_bays, [], [regex_mapping]) + assert result == 77 + + def test_exact_mapping_still_works(self): + """Exact mappings continue to work alongside regex mappings.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "ExactSlot", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + child = {"entPhysicalIndex": 20, "entPhysicalModelName": "MOD-A", "entPhysicalContainedIn": 10} + index_map = {10: parent, 20: child} + + bay = self._make_bay("Bay-1", installed_module_id=42) + exact_mapping = self._make_exact_mapping("ExactSlot", "Bay-1") + device_bays = [bay] + + result = InstallBranchView._find_parent_module_id(child, index_map, device_bays, [exact_mapping], []) + assert result == 42 + + def test_no_match_returns_none(self): + """Returns None when no exact or regex mapping matches the ancestor.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "UnknownSlot", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + child = {"entPhysicalIndex": 20, "entPhysicalModelName": "MOD-B", "entPhysicalContainedIn": 10} + index_map = {10: parent, 20: child} + + bay = self._make_bay("Bay-2", installed_module_id=99) + regex_mapping = self._make_regex_mapping(r"Slot \d+", "Bay-2") + device_bays = [bay] + + # "UnknownSlot" does not match pattern "Slot \d+" + result = InstallBranchView._find_parent_module_id(child, index_map, device_bays, [], [regex_mapping]) + assert result is None + + +# --------------------------------------------------------------------------- +# _find_parent_module_id β€” cycle detection and ancestor walk (Item 16) +# --------------------------------------------------------------------------- + + +class TestFindParentModuleIdAncestorWalk: + """_find_parent_module_id walks up the hierarchy and handles edge cases.""" + + def _make_bay(self, name, installed_module_id=None): + bay = MagicMock() + bay.name = name + if installed_module_id is not None: + bay.installed_module = MagicMock() + bay.installed_module.pk = installed_module_id + else: + bay.installed_module = None + return bay + + def test_direct_parent_matches_bay_name(self): + """Parent whose entPhysicalName matches a bay with an installed module.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "Slot 1", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + child = {"entPhysicalIndex": 20, "entPhysicalContainedIn": 10} + index_map = {10: parent, 20: child} + bay = self._make_bay("Slot 1", installed_module_id=55) + + result = InstallBranchView._find_parent_module_id(child, index_map, [bay], [], []) + assert result == 55 + + def test_parent_matched_by_descr_field(self): + """Parent matched via entPhysicalDescr when entPhysicalName doesn't match.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "SomethingElse", + "entPhysicalDescr": "Slot 1", + "entPhysicalContainedIn": 0, + } + child = {"entPhysicalIndex": 20, "entPhysicalContainedIn": 10} + index_map = {10: parent, 20: child} + bay = self._make_bay("Slot 1", installed_module_id=66) + + result = InstallBranchView._find_parent_module_id(child, index_map, [bay], [], []) + assert result == 66 + + def test_grandparent_walk(self): + """Walk two levels up to find an installed ancestor.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + grandparent = { + "entPhysicalIndex": 1, + "entPhysicalName": "FPC 0", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "Container", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 1, + } + child = {"entPhysicalIndex": 20, "entPhysicalContainedIn": 10} + index_map = {1: grandparent, 10: parent, 20: child} + bay = self._make_bay("FPC 0", installed_module_id=33) + + result = InstallBranchView._find_parent_module_id(child, index_map, [bay], [], []) + assert result == 33 + + def test_cycle_returns_none(self): + """Cycle in the hierarchy terminates without infinite loop.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + a = {"entPhysicalIndex": 1, "entPhysicalName": "A", "entPhysicalDescr": "", "entPhysicalContainedIn": 2} + b = {"entPhysicalIndex": 2, "entPhysicalName": "B", "entPhysicalDescr": "", "entPhysicalContainedIn": 1} + child = {"entPhysicalIndex": 3, "entPhysicalContainedIn": 1} + index_map = {1: a, 2: b, 3: child} + + result = InstallBranchView._find_parent_module_id(child, index_map, [], [], []) + assert result is None + + def test_missing_parent_index_returns_none(self): + """ContainedIn references a non-existent index.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = {"entPhysicalIndex": 20, "entPhysicalContainedIn": 999} + index_map = {20: child} + + result = InstallBranchView._find_parent_module_id(child, index_map, [], [], []) + assert result is None + + def test_root_item_returns_none(self): + """Item at root (containedIn=0) has no parent.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + root = {"entPhysicalIndex": 1, "entPhysicalContainedIn": 0} + index_map = {1: root} + + result = InstallBranchView._find_parent_module_id(root, index_map, [], [], []) + assert result is None + + def test_uninstalled_bay_is_skipped(self): + """Bay matches parent name but has no installed module β€” walk continues to grandparent.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + grandparent = { + "entPhysicalIndex": 5, + "entPhysicalName": "Chassis", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 0, + } + parent = { + "entPhysicalIndex": 10, + "entPhysicalName": "Slot 1", + "entPhysicalDescr": "", + "entPhysicalContainedIn": 5, + } + child = {"entPhysicalIndex": 20, "entPhysicalContainedIn": 10} + index_map = {5: grandparent, 10: parent, 20: child} + empty_bay = self._make_bay("Slot 1", installed_module_id=None) + grandparent_bay = self._make_bay("Chassis", installed_module_id=99) + + result = InstallBranchView._find_parent_module_id(child, index_map, [empty_bay, grandparent_bay], [], []) + assert result == 99 + + +# --------------------------------------------------------------------------- +# _match_bay β€” exact mapping, regex mapping, and fallbacks (Item 16) +# --------------------------------------------------------------------------- + + +class TestMatchBayLogic: + """InstallBranchView._match_bay matches items via mappings or name fallback.""" + + def _make_exact_mapping(self, librenms_name, netbox_bay_name, librenms_class=""): + m = MagicMock() + m.is_regex = False + m.librenms_name = librenms_name + m.netbox_bay_name = netbox_bay_name + m.librenms_class = librenms_class + m.manufacturer_id = None + return m + + def _make_regex_mapping(self, pattern, netbox_bay_name, librenms_class=""): + import re + + m = MagicMock() + m.is_regex = True + m.librenms_name = pattern + m.netbox_bay_name = netbox_bay_name + m.librenms_class = librenms_class + m._compiled_pattern = re.compile(pattern) + m.manufacturer_id = None + return m + + def test_exact_mapping_matches_parent_name(self): + """Exact mapping on the parent container name.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = {"entPhysicalIndex": 1, "entPhysicalName": "Rack 0-Slot 3", "entPhysicalContainedIn": 0} + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "SFP-1", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 1, + } + index_map = {1: parent, 2: child} + + bay = MagicMock() + bay.name = "Slot 3" + module_bays = {"Slot 3": bay} + exact = [self._make_exact_mapping("Rack 0-Slot 3", "Slot 3")] + + result = InstallBranchView._match_bay(child, index_map, module_bays, exact, []) + assert result is bay + + def test_exact_mapping_matches_item_name(self): + """Exact mapping on the item's own name (when parent has no name).""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + parent = {"entPhysicalIndex": 1, "entPhysicalName": "", "entPhysicalContainedIn": 0} + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "PSU 0", + "entPhysicalDescr": "", + "entPhysicalClass": "powerSupply", + "entPhysicalContainedIn": 1, + } + index_map = {1: parent, 2: child} + + bay = MagicMock() + bay.name = "PSU-Slot-0" + module_bays = {"PSU-Slot-0": bay} + exact = [self._make_exact_mapping("PSU 0", "PSU-Slot-0")] + + result = InstallBranchView._match_bay(child, index_map, module_bays, exact, []) + assert result is bay + + def test_class_scoped_mapping_preferred(self): + """Mapping with matching librenms_class preferred over classless mapping.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "Fan Tray 1", + "entPhysicalDescr": "", + "entPhysicalClass": "fan", + "entPhysicalContainedIn": 0, + } + index_map = {2: child} + + bay_generic = MagicMock() + bay_generic.name = "FanGeneric" + bay_fan = MagicMock() + bay_fan.name = "Fan-1" + module_bays = {"FanGeneric": bay_generic, "Fan-1": bay_fan} + exact = [ + self._make_exact_mapping("Fan Tray 1", "FanGeneric", librenms_class=""), + self._make_exact_mapping("Fan Tray 1", "Fan-1", librenms_class="fan"), + ] + + result = InstallBranchView._match_bay(child, index_map, module_bays, exact, []) + assert result is bay_fan + + # Order-independent: class-scoped mapping wins regardless of list order + result_reversed = InstallBranchView._match_bay(child, index_map, module_bays, list(reversed(exact)), []) + assert result_reversed is bay_fan + + def test_regex_mapping_with_backreference(self): + """Regex mapping with capture group + backreference in netbox_bay_name.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "Optics 0/0/0/5", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + } + index_map = {2: child} + + bay = MagicMock() + bay.name = "Optics0/0/0/5" + bay.module = None + module_bays = {"Optics0/0/0/5": bay} + regex = [self._make_regex_mapping(r"Optics (\d+/\d+/\d+/\d+)", r"Optics\1")] + + with patch( + "netbox_librenms_plugin.views.base.modules_view.BaseModuleTableView._fpc_slot_matches", return_value=True + ): + result = InstallBranchView._match_bay(child, index_map, module_bays, [], regex) + + assert result is bay + + def test_vendor_two_segment_regex_does_not_grab_three_segment_path(self): + """Regression for #59: a narrow 2-segment vendor regex must NOT swallow a 3-segment + transceiver name; the more-specific 3-segment generic mapping must win. + + ``_lookup_regex_bay_mapping`` uses ``fullmatch``, so a pattern with only two + slash-segments cannot consume a name with three. Both mappings are passed in + together (mirroring real callers that have already merged scoped + global + regex mappings via ``_filter_mappings_by_manufacturer``). + """ + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "Optics 0/0/5", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + } + index_map = {2: child} + + # The two-segment bay must NOT be selected for a three-segment LibreNMS name + narrow_bay = MagicMock() + narrow_bay.name = "Optics0/0" + narrow_bay.module = None + wide_bay = MagicMock() + wide_bay.name = "Optics0/0/5" + wide_bay.module = None + module_bays = {"Optics0/0": narrow_bay, "Optics0/0/5": wide_bay} + + # Vendor-scoped narrow regex first (would mis-fire if iteration order + # alone decided the winner) + generic wider regex second. + narrow = self._make_regex_mapping(r"Optics (\d+/\d+)", r"Optics\1") + narrow.manufacturer_id = 7 # manufacturer-scoped + wide = self._make_regex_mapping(r"Optics (\d+/\d+/\d+)", r"Optics\1") + wide.manufacturer_id = None # global + + with patch( + "netbox_librenms_plugin.views.base.modules_view.BaseModuleTableView._fpc_slot_matches", return_value=True + ): + result = InstallBranchView._match_bay(child, index_map, module_bays, [], [narrow, wide]) + + assert result is wide_bay, "narrow 2-segment vendor regex must not grab a 3-segment transceiver path" + + def test_fallback_exact_name_match(self): + """Falls back to direct name-in-bays-dict when no mappings match.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "0/FT0", + "entPhysicalDescr": "", + "entPhysicalClass": "fan", + "entPhysicalContainedIn": 0, + } + index_map = {2: child} + + bay = MagicMock() + bay.name = "0/FT0" + module_bays = {"0/FT0": bay} + + with patch( + "netbox_librenms_plugin.views.base.modules_view.BaseModuleTableView._match_bay_by_position", + return_value=None, + ): + result = InstallBranchView._match_bay(child, index_map, module_bays, [], []) + + assert result is bay + + def test_no_match_returns_none(self): + """Returns None when nothing matches.""" + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + child = { + "entPhysicalIndex": 2, + "entPhysicalName": "Unknown", + "entPhysicalDescr": "", + "entPhysicalClass": "module", + "entPhysicalContainedIn": 0, + } + index_map = {2: child} + + with patch( + "netbox_librenms_plugin.views.base.modules_view.BaseModuleTableView._match_bay_by_position", + return_value=None, + ): + result = InstallBranchView._match_bay(child, index_map, {}, [], []) + + assert result is None + + +# --------------------------------------------------------------------------- +# _lookup_regex_bay_mapping β€” stale resolved_bay regression (Item 1) +# --------------------------------------------------------------------------- + + +class TestLookupRegexBayMappingStaleResolvedBay: + """Regression: a failed expand() must not use resolved_bay from a prior iteration.""" + + def _make_mapping(self, pattern, netbox_bay_name): + import re + + m = MagicMock() + m._compiled_pattern = re.compile(pattern) + m.netbox_bay_name = netbox_bay_name + m.librenms_class = "" + m.manufacturer_id = None + return m + + def test_failed_expand_does_not_match_stale_bay(self): + """If expand() raises, the stale resolved_bay from a prior mapping must not be used.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + # Mapping A: matches, expand succeeds, but bay name not in module_bays. + # Defined for documentation but replaced by mapping_a_no_bay below. + # Mapping B: matches, but expand raises IndexError (bad backreference) + mapping_b = self._make_mapping(r"Port-\d+", r"Bay-\2") # \2 doesn't exist + + # Wrap mapping_b's compiled pattern so we can confirm the loop actually + # reached mapping_b (and it wasn't short-circuited by a stale match from A). + original_fullmatch = mapping_b._compiled_pattern.fullmatch + mapping_b._compiled_pattern = MagicMock() + mapping_b._compiled_pattern.fullmatch.side_effect = original_fullmatch + + stale_bay = MagicMock() + stale_bay.name = "Bay-5" + module_bays = {"Bay-5": stale_bay} + + # Before the fix, mapping_a sets resolved_bay="Bay-5" (no match in bays? let's say it does), + # then mapping_b fails expand but the outer `if match:` used stale resolved_bay="Bay-5". + # To properly trigger the bug: mapping_a's expand result is NOT in module_bays, + # then mapping_b's expand fails, and the stale value from A should NOT be used. + mapping_a_no_bay = self._make_mapping(r"Port-(\d+)", r"NoSuchBay-\1") + + with patch.object(BaseModuleTableView, "_fpc_slot_matches", return_value=True): + result = BaseModuleTableView._lookup_regex_bay_mapping( + "Port-5", "", module_bays, [mapping_a_no_bay, mapping_b] + ) + + assert result is None, "Failed expand() must not fall through to stale resolved_bay" + # Confirm the loop actually iterated to mapping_b β€” otherwise the regression + # test would silently pass even if the bug were re-introduced via short-circuit. + mapping_b._compiled_pattern.fullmatch.assert_called_once_with("Port-5") + + def test_successful_expand_matches_bay(self): + """Happy path: expand succeeds and bay exists.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + mapping = self._make_mapping(r"Optics(\d+/\d+/\d+/\d+)", r"Optics\1") + bay = MagicMock() + bay.name = "Optics0/0/0/3" + module_bays = {"Optics0/0/0/3": bay} + + with patch.object(BaseModuleTableView, "_fpc_slot_matches", return_value=True): + result = BaseModuleTableView._lookup_regex_bay_mapping("Optics0/0/0/3", "", module_bays, [mapping]) + + assert result is bay + + def test_no_match_returns_none(self): + """No mapping matches the name.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + mapping = self._make_mapping(r"Slot-\d+", r"Bay-\1") + result = BaseModuleTableView._lookup_regex_bay_mapping("Port-5", "", {}, [mapping]) + assert result is None + + def test_class_scoped_mapping_preferred(self): + """Mappings with matching class are tried before classless fallback.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + # Class-scoped mapping (matches) + m_class = self._make_mapping(r"Fan-(\d+)", r"FanBay-\1") + m_class.librenms_class = "fan" + # Classless mapping (would also match but lower priority) + m_generic = self._make_mapping(r"Fan-(\d+)", r"GenericBay-\1") + m_generic.librenms_class = "" + + fan_bay = MagicMock() + fan_bay.name = "FanBay-3" + module_bays = {"FanBay-3": fan_bay, "GenericBay-3": MagicMock()} + + with patch.object(BaseModuleTableView, "_fpc_slot_matches", return_value=True): + result = BaseModuleTableView._lookup_regex_bay_mapping("Fan-3", "fan", module_bays, [m_generic, m_class]) + + assert result is fan_bay + + +class TestFilterMappingsByManufacturer: + """_filter_mappings_by_manufacturer scopes ModuleBayMapping entries by vendor.""" + + @staticmethod + def _mk(manufacturer_id): + m = MagicMock() + m.manufacturer_id = manufacturer_id + return m + + def test_global_mapping_used_when_device_has_no_manufacturer(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + glob = self._mk(None) + scoped = self._mk(7) + result = BaseModuleTableView._filter_mappings_by_manufacturer([glob, scoped], None) + assert result == [glob] + + def test_scoped_mapping_preferred_over_global_for_matching_vendor(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + glob = self._mk(None) + scoped = self._mk(7) + result = BaseModuleTableView._filter_mappings_by_manufacturer([glob, scoped], 7) + assert result == [scoped, glob] + + def test_other_vendor_scoped_mapping_skipped(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + glob = self._mk(None) + other = self._mk(99) + result = BaseModuleTableView._filter_mappings_by_manufacturer([glob, other], 7) + assert result == [glob] + + +# --------------------------------------------------------------------------- +# PK validation error paths (finding 14) +# --------------------------------------------------------------------------- + + +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 + + +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 + + +class TestPKValidationErrorPaths: + """Views must reject non-numeric PK values with an error message and redirect.""" + + # -- InstallModuleView.post: non-numeric module_bay_id ----------------- + + def test_install_module_non_numeric_bay_id(self): + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + + view = object.__new__(InstallModuleView) + view.required_object_permissions = {} + device = _make_device() + request = _make_request( + "POST", + data={ + "module_bay_id": "not-a-number", + "module_type_id": "5", + "serial": "SN1", + }, + ) + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + 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() + assert "invalid" in mock_msg.error.call_args[0][1].lower() + mock_redirect.assert_called_once() + + # -- InstallBranchView.post: non-numeric parent_index ------------------ + + def test_install_branch_non_numeric_parent_index(self): + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + view = object.__new__(InstallBranchView) + view.required_object_permissions = {} + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + device = _make_device() + request = _make_request( + "POST", + data={ + "parent_index": "abc", + }, + ) + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + 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() + assert "invalid" in mock_msg.error.call_args[0][1].lower() + mock_redirect.assert_called_once() + + # -- UpdateModuleSerialView.post: non-numeric module_id ---------------- + + def test_update_serial_non_numeric_module_id(self): + from netbox_librenms_plugin.views.sync.modules import UpdateModuleSerialView + + view = object.__new__(UpdateModuleSerialView) + view.required_object_permissions = {} + device = _make_device() + request = _make_request( + "POST", + data={ + "module_id": "xyz", + "serial": "NEW-SN", + }, + ) + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + 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() + assert "invalid" in mock_msg.error.call_args[0][1].lower() + mock_redirect.assert_called_once() + + # -- InstallSelectedView.post: non-numeric select values --------------- + + def test_install_selected_non_numeric_select(self): + from netbox_librenms_plugin.views.sync.modules import InstallSelectedView + + view = object.__new__(InstallSelectedView) + view.required_object_permissions = {} + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + device = _make_device() + # POST.getlist must return a list with non-numeric values + request = _make_request("POST", data={}) + post_data = {} + post_mock = MagicMock() + post_mock.get = MagicMock(side_effect=lambda k, d=None: post_data.get(k, d)) + post_mock.getlist = MagicMock(return_value=["a", "b"]) + request.POST = post_mock + + cached = [{"entPhysicalIndex": 1, "entPhysicalModelName": "M1"}] + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch.object(view, "get_cache_key", return_value="ck"), + 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 = {"inventory": cached, "librenms_id": "test"} + view.post(request, pk=24) + + mock_msg.error.assert_called_once() + assert "invalid" in mock_msg.error.call_args[0][1].lower() + mock_redirect.assert_called_once() + + +# --------------------------------------------------------------------------- +# Basic behavioral tests β€” InstallModuleView (finding 15) +# --------------------------------------------------------------------------- + + +class TestInstallModuleViewBehavior: + """Behavioral tests for InstallModuleView.post happy and occupied paths.""" + + def _view(self): + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + + v = object.__new__(InstallModuleView) + v.required_object_permissions = {} + return v + + def test_bay_already_occupied_warns(self): + """POST where module_bay has installed_module produces a warning and redirects.""" + from contextlib import contextmanager + + from dcim.models import ModuleBay + + view = self._view() + device = _make_device() + + module_bay = MagicMock() + module_bay.name = "Slot 1" + module_bay.installed_module = MagicMock() # occupied + + module_type = MagicMock() + module_type.pk = 5 + + request = _make_request( + "POST", + data={ + "module_bay_id": "10", + "module_type_id": "5", + "serial": "SN1", + }, + ) + + @contextmanager + def noop_atomic(): + yield + + mock_qs = MagicMock() + mock_qs.get.return_value = module_bay # locked re-fetch returns same occupied bay + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, module_bay, module_type], + ), + 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") as mock_redirect, + patch.object(ModuleBay, "objects") as mock_objects, + ): + mock_tx.atomic = noop_atomic + mock_objects.select_for_update.return_value = mock_qs + view.post(request, pk=24) + + mock_msg.warning.assert_called_once() + assert "already has a module" in mock_msg.warning.call_args[0][1] + mock_redirect.assert_called_once() + + def test_successful_install(self): + """POST happy path: module is created and success message is shown.""" + from contextlib import contextmanager + + from dcim.models import ModuleBay + + view = self._view() + device = _make_device() + + module_bay = MagicMock() + module_bay.name = "Slot 1" + module_bay.installed_module = None # not occupied + + module_type = MagicMock() + module_type.pk = 5 + module_type.model = "XCM-7s" + + new_module = MagicMock() + + request = _make_request( + "POST", + data={ + "module_bay_id": "10", + "module_type_id": "5", + "serial": "SN123", + }, + ) + + @contextmanager + def noop_atomic(): + yield + + mock_qs = MagicMock() + mock_qs.get.return_value = module_bay # locked re-fetch returns same bay + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + side_effect=[device, module_bay, module_type], + ), + 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") as mock_redirect, + patch("dcim.models.Module") as mock_module_cls, + patch.object(ModuleBay, "objects") as mock_objects, + ): + mock_tx.atomic = noop_atomic + mock_module_cls.return_value = new_module + mock_objects.select_for_update.return_value = mock_qs + view.post(request, pk=24) + + new_module.full_clean.assert_called_once() + new_module.save.assert_called_once() + mock_msg.success.assert_called_once() + assert "XCM-7s" in mock_msg.success.call_args[0][1] + mock_redirect.assert_called_once() + + +# --------------------------------------------------------------------------- +# Basic behavioral tests β€” UpdateModuleSerialView (finding 15) +# --------------------------------------------------------------------------- + + +class TestUpdateModuleSerialViewBehavior: + """Behavioral tests for UpdateModuleSerialView.post happy path.""" + + def _view(self): + from netbox_librenms_plugin.views.sync.modules import UpdateModuleSerialView + + v = object.__new__(UpdateModuleSerialView) + v.required_object_permissions = {} + return v + + def test_updates_serial_successfully(self): + """POST with valid module_id and new serial updates the module and shows success.""" + from contextlib import contextmanager + + from dcim.models import Module + + view = self._view() + device = _make_device() + + module = MagicMock() + module.pk = 42 + module.serial = "OLD-SN" + module.module_type = MagicMock() + module.module_type.model = "XCM-7s" + module.module_bay = MagicMock() + module.module_bay.name = "Slot 1" + + request = _make_request( + "POST", + data={ + "module_id": "42", + "serial": "NEW-SN", + }, + ) + + @contextmanager + def noop_atomic(): + yield + + mock_qs = MagicMock() + mock_qs.select_related.return_value.filter.return_value.first.return_value = module + + with ( + patch.object(view, "require_all_permissions", return_value=None), + patch( + "netbox_librenms_plugin.views.sync.modules.get_object_or_404", + return_value=device, + ), + 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") as mock_redirect, + patch.object(Module, "objects") as mock_objects, + ): + mock_tx.atomic = noop_atomic + mock_objects.select_for_update.return_value = mock_qs + view.post(request, pk=24) + + assert module.serial == "NEW-SN" + module.full_clean.assert_called_once() + module.save.assert_called_once() + mock_msg.success.assert_called_once() + assert "NEW-SN" in mock_msg.success.call_args[0][1] + mock_redirect.assert_called_once() + + +# ============================================================================= +# TestBuildTableRowsBayCollisionDetection (Issue #65) +# ============================================================================= + + +class TestBuildTableRowsBayCollisionDetection: + """_build_table_rows merges module-scoped bays into a deterministic flat dict. + + When two modules expose bays with the same name the module with the lower PK + wins (first-match-wins with sorted module IDs). Device-level bays always + take precedence over module-scoped bays. + """ + + def _make_bay(self, name, pk): + from unittest.mock import MagicMock + + bay = MagicMock() + bay.name = name + bay.pk = pk + return bay + + def test_no_collision_passthrough(self): + """When bay names are unique across modules, all bays are in all_bays.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = BaseModuleTableView.__new__(BaseModuleTableView) + bay_a = self._make_bay("Slot 1", 10) + bay_b = self._make_bay("Slot 2", 11) + + device_bays = {} + module_scoped_bays = { + 1: {"Slot 1": bay_a}, + 2: {"Slot 2": bay_b}, + } + + all_bays = view._compute_all_bays(device_bays, module_scoped_bays) + assert "Slot 1" in all_bays + assert "Slot 2" in all_bays + assert all_bays["Slot 1"] is bay_a + assert all_bays["Slot 2"] is bay_b + + def test_collision_lower_pk_wins(self): + """When two modules share a bay name, the one with the lower module PK wins.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = BaseModuleTableView.__new__(BaseModuleTableView) + bay_low = self._make_bay("Slot 1", 5) + bay_high = self._make_bay("Slot 1", 99) + + device_bays = {} + module_scoped_bays = { + 10: {"Slot 1": bay_high}, # lower module PK gets processed first + 2: {"Slot 1": bay_low}, + } + + all_bays = view._compute_all_bays(device_bays, module_scoped_bays) + # Module PK 2 < 10 β†’ bay_low wins + assert all_bays["Slot 1"] is bay_low + + def test_device_bays_take_precedence_over_module_bays(self): + """Device-level bays always override same-named module-scoped bays.""" + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = BaseModuleTableView.__new__(BaseModuleTableView) + device_bay = self._make_bay("Slot 1", 1) + module_bay = self._make_bay("Slot 1", 2) + + device_bays = {"Slot 1": device_bay} + module_scoped_bays = {1: {"Slot 1": module_bay}} + + all_bays = view._compute_all_bays(device_bays, module_scoped_bays) + assert all_bays["Slot 1"] is device_bay + + def test_collision_logs_debug(self, caplog): + """Collision names are logged at DEBUG level.""" + import logging + + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + view = BaseModuleTableView.__new__(BaseModuleTableView) + bay_a = self._make_bay("Slot 1", 1) + bay_b = self._make_bay("Slot 1", 2) + + device_bays = {} + module_scoped_bays = {1: {"Slot 1": bay_a}, 2: {"Slot 1": bay_b}} + + with caplog.at_level(logging.DEBUG, logger="netbox_librenms_plugin.views.base.modules_view"): + view._compute_all_bays(device_bays, module_scoped_bays) + + assert any("Slot 1" in msg for msg in caplog.messages) + + +class TestModulesRedirectResponse: + """_modules_redirect_response: HX-Request β†’ HX-Redirect; classic β†’ redirect().""" + + def test_classic_request_uses_redirect(self): + from unittest.mock import MagicMock, patch + + from netbox_librenms_plugin.views.sync.modules import _modules_redirect_response + + req = MagicMock() + req.headers = {} + with patch("netbox_librenms_plugin.views.sync.modules.redirect") as mock_redirect: + mock_redirect.return_value = "REDIRECT" + result = _modules_redirect_response(req, "/sync/") + mock_redirect.assert_called_once_with("/sync/?tab=modules#librenms-module-table") + assert result == "REDIRECT" + + def test_htmx_request_returns_hx_redirect_header(self): + from unittest.mock import MagicMock + + from netbox_librenms_plugin.views.sync.modules import _modules_redirect_response + + req = MagicMock() + req.headers = {"HX-Request": "true"} + response = _modules_redirect_response(req, "/sync/") + assert response.status_code == 204 + assert response["HX-Redirect"] == "/sync/?tab=modules#librenms-module-table" + + +class TestAddBayTemplateViewWiring: + """AddBayTemplateView must have the right mixins and target kinds.""" + + def test_inherits_required_mixins(self): + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin, NetBoxObjectPermissionMixin + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + assert LibreNMSPermissionMixin in AddBayTemplateView.__mro__ + assert NetBoxObjectPermissionMixin in AddBayTemplateView.__mro__ + + def test_target_kinds_are_device_type_and_module_type(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + assert AddBayTemplateView.TARGET_KINDS == ("device_type", "module_type") + + +class TestAddBayTemplateViewPostValidation: + """POST input validation: target_kind, target_pk, name.""" + + def _make_view(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + view = object.__new__(AddBayTemplateView) + # Bypass perm checks: require_all_permissions returns None on success. + view.require_all_permissions = MagicMock(return_value=None) + return view + + def _make_request(self, post_data, htmx=False): + req = MagicMock() + req.method = "POST" + req.POST = post_data + req.headers = {"HX-Request": "true"} if htmx else {} + return req + + def test_invalid_target_kind_returns_redirect(self): + view = self._make_view() + req = self._make_request({"target_kind": "bogus", "target_pk": "1", "name": "Slot 1"}) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_messages, + patch( + "netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="REDIR" + ) as mock_redir, + ): + result = view.post(req, pk=1) + assert result == "REDIR" + assert mock_messages.error.called + assert "Invalid target_kind" in mock_messages.error.call_args[0][1] + mock_redir.assert_called_once() + + def test_missing_target_pk_returns_redirect(self): + view = self._make_view() + req = self._make_request({"target_kind": "device_type", "target_pk": "", "name": "Slot 1"}) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="REDIR"), + ): + view.post(req, pk=1) + assert "target_pk" in mock_messages.error.call_args[0][1] + + def test_missing_name_returns_redirect(self): + view = self._make_view() + req = self._make_request({"target_kind": "module_type", "target_pk": "5", "name": " "}) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.modules.reverse", return_value="/sync/"), + patch("netbox_librenms_plugin.views.sync.modules.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="REDIR"), + ): + view.post(req, pk=1) + assert "name is required" in mock_messages.error.call_args[0][1] + + +class TestAddBayTemplateViewGetValidation: + """GET modal renderer validates query-string inputs.""" + + def _make_view(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + view = object.__new__(AddBayTemplateView) + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_invalid_target_kind_returns_400(self): + view = self._make_view() + req = MagicMock() + req.GET = {"target_kind": "nope", "target_pk": "1"} + with patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()): + response = view.get(req, pk=1) + assert response.status_code == 400 + + def test_invalid_target_pk_returns_400(self): + view = self._make_view() + req = MagicMock() + req.GET = {"target_kind": "device_type", "target_pk": "abc"} + with patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()): + response = view.get(req, pk=1) + assert response.status_code == 400 + + def test_valid_request_renders_modal_with_suggestions(self): + view = self._make_view() + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "suggested_name": "Fan Tray 0", + "suggested_position": "0", + "suggested_label": "Fan Controller", + } + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="RENDERED") as mock_render, + ): + response = view.get(req, pk=42) + assert response == "RENDERED" + ctx = mock_render.call_args[0][2] + assert ctx["device_pk"] == 42 + assert ctx["target_kind"] == "device_type" + assert ctx["target_pk"] == 7 + assert ctx["suggested_name"] == "Fan Tray 0" + assert ctx["suggested_position"] == "0" + assert ctx["suggested_label"] == "Fan Controller" + # Without a librenms_name in the GET, the auto-mapping checkbox is + # never offered (there's nothing to map from). + assert ctx["offer_mapping_checkbox"] is False + + +class TestAddBayTemplateViewMappingCheckbox: + """GET threads librenms_name/class into context and decides whether to + show the auto-create-mapping checkbox. POST creates the mapping when the + user opts in and the NetBox name differs from the LibreNMS one.""" + + def _make_view(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + view = object.__new__(AddBayTemplateView) + view.require_all_permissions = MagicMock(return_value=None) + view._instantiate_template_on_existing = MagicMock(return_value=0) + return view + + def _device_with_manufacturer(self, manufacturer=None): + device = MagicMock() + device.device_type.manufacturer = manufacturer + return device + + def test_get_offers_checkbox_when_libre_name_present_and_no_existing_mapping(self): + view = self._make_view() + manufacturer = MagicMock() + manufacturer.__str__ = lambda s: "Nokia" + device = self._device_with_manufacturer(manufacturer) + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "suggested_name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["librenms_name"] == "Sfm 1" + assert ctx["librenms_class"] == "fabricModule" + assert ctx["manufacturer_label"] == "Nokia" + assert ctx["offer_mapping_checkbox"] is True + assert ctx["mapping_exists"] is False + + def test_get_suppresses_checkbox_when_existing_mapping(self): + view = self._make_view() + device = self._device_with_manufacturer(MagicMock()) + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = True + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["mapping_exists"] is True + assert ctx["offer_mapping_checkbox"] is False + + def test_get_suppresses_checkbox_when_user_lacks_add_mapping_perm(self): + view = self._make_view() + device = self._device_with_manufacturer(MagicMock()) + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "librenms_name": "Sfm 1", + } + req.user.has_perm.return_value = False # lacks add_modulebaymapping + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["offer_mapping_checkbox"] is False + + def test_post_creates_mapping_when_checkbox_set_and_names_differ(self): + view = self._make_view() + manufacturer = MagicMock() + device = self._device_with_manufacturer(manufacturer) + req = MagicMock() + req.method = "POST" + req.POST = { + "target_kind": "device_type", + "target_pk": "7", + "name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + "also_create_mapping": "1", + } + req.user.has_perm.return_value = True + target = MagicMock() + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, target]), + 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._modules_redirect_response", return_value="REDIR"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + # No existing mapping + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mapping_instance = MagicMock() + mock_mapping_cls.return_value = mapping_instance + view.post(req, pk=1) + # ModuleBayMapping was constructed with the right field values + call_kwargs = mock_mapping_cls.call_args.kwargs + assert call_kwargs["librenms_name"] == "Sfm 1" + assert call_kwargs["netbox_bay_name"] == "SFM 1" + assert call_kwargs["librenms_class"] == "fabricModule" + assert call_kwargs["is_regex"] is False + assert call_kwargs["manufacturer"] is manufacturer + mapping_instance.full_clean.assert_called_once() + mapping_instance.save.assert_called_once() + # Success message mentions both bay and mapping + assert any("ModuleBayMapping" in c.args[1] for c in mock_msg.success.call_args_list) + + def test_post_skips_mapping_when_names_match(self): + view = self._make_view() + device = self._device_with_manufacturer(MagicMock()) + req = MagicMock() + req.method = "POST" + req.POST = { + "target_kind": "device_type", + "target_pk": "7", + "name": "Sfm 1", # same as LibreNMS β€” no mapping needed + "librenms_name": "Sfm 1", + "also_create_mapping": "1", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, MagicMock()]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="REDIR"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + view.post(req, pk=1) + # Mapping must not be instantiated when names match + mock_mapping_cls.assert_not_called() + + def test_post_does_not_create_mapping_without_checkbox(self): + view = self._make_view() + device = self._device_with_manufacturer(MagicMock()) + req = MagicMock() + req.method = "POST" + req.POST = { + "target_kind": "device_type", + "target_pk": "7", + "name": "SFM 1", + "librenms_name": "Sfm 1", + # also_create_mapping omitted + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, MagicMock()]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="REDIR"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + view.post(req, pk=1) + mock_mapping_cls.assert_not_called() + + +class TestAddBayTemplateViewInstantiation: + """After saving a ModuleBayTemplate, the view materialises it onto every + existing Device/Module of the target so the resolver can match the new bay + immediately (NetBox only auto-creates bays from templates at first-create + time).""" + + def test_instantiate_on_existing_device_type_creates_missing_bays(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + device_type = MagicMock() + bay_template = MagicMock() + bay_template.instantiate.side_effect = lambda device: MagicMock(name=f"bay-for-{id(device)}") + dev_a, dev_b = MagicMock(), MagicMock() + with ( + patch("dcim.models.Device") as mock_device_cls, + patch("dcim.models.Module") as mock_module_cls, + patch("dcim.models.ModuleBay") as mock_bay_cls, + ): + mock_device_cls.objects.filter.return_value = [dev_a, dev_b] + mock_bay_cls.objects.filter.return_value.exists.return_value = False + count = AddBayTemplateView._instantiate_template_on_existing(bay_template, "device_type", device_type) + assert count == 2 + mock_device_cls.objects.filter.assert_called_once_with(device_type=device_type) + mock_module_cls.objects.filter.assert_not_called() + assert bay_template.instantiate.call_count == 2 + + def test_instantiate_skips_devices_that_already_have_the_bay(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + device_type = MagicMock() + bay_template = MagicMock() + existing_bay = MagicMock() + existing_bay.name = "Fan Tray 0" + existing_bay.full_clean = MagicMock() + existing_bay.save = MagicMock() + bay_template.instantiate.return_value = existing_bay + with ( + patch("dcim.models.Device") as mock_device_cls, + patch("dcim.models.Module"), + patch("dcim.models.ModuleBay") as mock_bay_cls, + ): + mock_device_cls.objects.filter.return_value = [MagicMock()] + # Bay already exists on the device + mock_bay_cls.objects.filter.return_value.exists.return_value = True + count = AddBayTemplateView._instantiate_template_on_existing(bay_template, "device_type", device_type) + assert count == 0 + existing_bay.save.assert_not_called() + + def test_instantiate_on_existing_module_type_uses_module_queryset(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + module_type = MagicMock() + module_a = MagicMock() + bay_template = MagicMock() + bay_template.instantiate.return_value = MagicMock(name="bay") + with ( + patch("dcim.models.Device") as mock_device_cls, + patch("dcim.models.Module") as mock_module_cls, + patch("dcim.models.ModuleBay") as mock_bay_cls, + ): + mock_module_cls.objects.filter.return_value.select_related.return_value = [module_a] + mock_bay_cls.objects.filter.return_value.exists.return_value = False + count = AddBayTemplateView._instantiate_template_on_existing(bay_template, "module_type", module_type) + assert count == 1 + mock_module_cls.objects.filter.assert_called_once_with(module_type=module_type) + mock_device_cls.objects.filter.assert_not_called() + bay_template.instantiate.assert_called_once_with(device=module_a.device, module=module_a) + + def test_instantiate_unknown_target_kind_is_noop(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + bay_template = MagicMock() + with ( + patch("dcim.models.Device") as mock_device_cls, + patch("dcim.models.Module") as mock_module_cls, + patch("dcim.models.ModuleBay"), + ): + count = AddBayTemplateView._instantiate_template_on_existing(bay_template, "bogus", MagicMock()) + assert count == 0 + mock_device_cls.objects.filter.assert_not_called() + mock_module_cls.objects.filter.assert_not_called() + bay_template.instantiate.assert_not_called() + + """_derive_bay_template_suggestion infers sane defaults from item dicts.""" + + def _helper(self): + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + return BaseModuleTableView._derive_bay_template_suggestion + + def test_extracts_trailing_digit_position(self): + result = self._helper()({"entPhysicalName": "Slot 3", "entPhysicalDescr": "Line Card"}) + assert result["name"] == "Slot 3" + assert result["position"] == "3" + assert result["label"] == "Line Card" + + def test_extracts_trailing_letter_position(self): + result = self._helper()({"entPhysicalName": "CMA-A"}) + assert result["name"] == "CMA-A" + assert result["position"] == "A" + + def test_falls_back_to_class_placeholder_for_fan(self): + result = self._helper()({"entPhysicalName": "", "entPhysicalClass": "fan"}) + assert "Fan Tray" in result["name"] + assert result["position"] == "1" + + def test_falls_back_to_class_placeholder_for_powersupply(self): + result = self._helper()({"entPhysicalName": "", "entPhysicalClass": "powerSupply"}) + assert "Power Supply" in result["name"] + + def test_falls_back_to_slot_for_unknown_class(self): + result = self._helper()({"entPhysicalName": "", "entPhysicalClass": "module"}) + assert result["name"] == "Slot 1" + + +class TestDeriveMappingPattern: + """_derive_mapping_pattern maps each distinct LibreNMS digit value to a + capture group; the NetBox replacement may use any literals as long as + every NetBox digit value is present on the LibreNMS side and the + pattern round-trips.""" + + def _fn(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + return AddBayTemplateView._derive_mapping_pattern + + def test_case_only_difference_with_one_digit(self): + result = self._fn()("Sfm 1", "SFM 1") + assert result is not None + assert result["librenms_pattern"] == r"^Sfm\ (\d+)$" + assert result["netbox_replacement"] == r"SFM \1" + assert result["digit_count"] == 1 + + def test_identical_names_with_digit(self): + result = self._fn()("Slot 0", "Slot 0") + assert result is not None + assert result["digit_count"] == 1 + + def test_multi_digit_skeleton_collapses_repeat_to_backref(self): + # All four digits have the same value '0', so they collapse to a + # single group with back-references rather than four groups. + result = self._fn()("TenGigE0/0/0/0", "TenGigE0/0/0/0") + assert result is not None + assert result["digit_count"] == 1 + assert result["librenms_pattern"] == r"^TenGigE(\d+)/\1/\1/\1$" + assert result["netbox_replacement"] == r"TenGigE\1/\1/\1/\1" + + def test_libre_and_nb_have_different_skeletons(self): + # 0/FT0 β†’ Fan Tray 0: both libre digits are '0' so they collapse to + # one group, and NetBox '0' references that group. + result = self._fn()("0/FT0", "Fan Tray 0") + assert result is not None + assert result["librenms_pattern"] == r"^(\d+)/FT\1$" + assert result["netbox_replacement"] == r"Fan Tray \1" + + def test_literal_difference_with_shared_digit(self): + # Literals on libre and NetBox are unrelated β€” that's fine, they + # don't need to match. Digit '1' is shared. + result = self._fn()("Sfm 1", "Card 1") + assert result is not None + assert result["netbox_replacement"] == r"Card \1" + + def test_nb_digit_absent_from_libre_returns_none(self): + # NetBox uses digit '2' which is nowhere on the libre side. + assert self._fn()("Slot 1", "Slot 2") is None + + def test_no_digit_run_returns_none(self): + assert self._fn()("Slot A", "Slot A") is None + + def test_extra_nb_digit_absent_from_libre_returns_none(self): + # NetBox has an extra digit '0' that libre never produces. + assert self._fn()("Slot 1", "Slot 1/0") is None + + def test_empty_input_returns_none(self): + assert self._fn()("", "Slot 1") is None + assert self._fn()("Slot 1", "") is None + + +class TestAddBayTemplateViewRegexMapping: + """GET surfaces a derived regex pattern; POST stores it as is_regex=True.""" + + def _make_view(self): + from netbox_librenms_plugin.views.sync.modules import AddBayTemplateView + + view = object.__new__(AddBayTemplateView) + view.require_all_permissions = MagicMock(return_value=None) + view._instantiate_template_on_existing = MagicMock(return_value=0) + return view + + def _device(self, manufacturer=None): + device = MagicMock() + device.device_type.manufacturer = manufacturer + return device + + def test_get_includes_mapping_pattern_when_skeleton_matches(self): + view = self._make_view() + manufacturer = MagicMock() + manufacturer.__str__ = lambda s: "Nokia" + device = self._device(manufacturer) + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "suggested_name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [] + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["mapping_pattern"] is not None + assert ctx["mapping_default_kind"] == "regex" + assert ctx["mapping_pattern"]["librenms_pattern"].startswith("^Sfm") + + def test_get_omits_mapping_pattern_when_no_digit(self): + view = self._make_view() + device = self._device(MagicMock()) + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "suggested_name": "Card A", + "librenms_name": "card a", + "librenms_class": "module", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [] + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["mapping_pattern"] is None + assert ctx["mapping_default_kind"] == "exact" + + def test_get_skips_checkbox_when_existing_regex_covers_name(self): + view = self._make_view() + device = self._device(MagicMock()) + existing = MagicMock() + existing.librenms_name = r"^Sfm (\d+)$" + req = MagicMock() + req.GET = { + "target_kind": "device_type", + "target_pk": "7", + "suggested_name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + } + req.user.has_perm.return_value = True + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.sync.modules.render", return_value="R") as mock_render, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + # No exact mapping, but a covering regex row exists. + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [existing] + view.get(req, pk=42) + ctx = mock_render.call_args[0][2] + assert ctx["mapping_exists"] is True + assert ctx["offer_mapping_checkbox"] is False + + def _post_helper(self, post_data): + view = self._make_view() + manufacturer = MagicMock() + device = self._device(manufacturer) + req = MagicMock() + req.method = "POST" + req.POST = post_data + req.user.has_perm.return_value = True + target = MagicMock() + return view, req, device, target, manufacturer + + def test_post_creates_regex_mapping_when_kind_regex_and_pattern_derives(self): + view, req, device, target, manufacturer = self._post_helper( + { + "target_kind": "device_type", + "target_pk": "7", + "name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + "also_create_mapping": "1", + "mapping_kind": "regex", + } + ) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, target]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="R"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + # No existing exact or regex coverage. + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [] + mock_mapping_cls.return_value = MagicMock() + view.post(req, pk=1) + kwargs = mock_mapping_cls.call_args.kwargs + assert kwargs["is_regex"] is True + assert kwargs["librenms_name"] == r"^Sfm\ (\d+)$" + assert kwargs["netbox_bay_name"] == r"SFM \1" + assert kwargs["manufacturer"] is manufacturer + + def test_post_falls_back_to_exact_when_pattern_does_not_derive(self): + # mapping_kind=regex requested but server-side rule says no. + view, req, device, target, _m = self._post_helper( + { + "target_kind": "device_type", + "target_pk": "7", + "name": "Card B", + "librenms_name": "card a", # No digit run β†’ no pattern. + "librenms_class": "module", + "also_create_mapping": "1", + "mapping_kind": "regex", + } + ) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, target]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="R"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [] + mock_mapping_cls.return_value = MagicMock() + view.post(req, pk=1) + kwargs = mock_mapping_cls.call_args.kwargs + assert kwargs["is_regex"] is False + assert kwargs["librenms_name"] == "card a" + assert kwargs["netbox_bay_name"] == "Card B" + + def test_post_kind_exact_overrides_derivable_pattern(self): + # Pattern would derive, but user explicitly chose exact. + view, req, device, target, _m = self._post_helper( + { + "target_kind": "device_type", + "target_pk": "7", + "name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + "also_create_mapping": "1", + "mapping_kind": "exact", + } + ) + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, target]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="R"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [] + mock_mapping_cls.return_value = MagicMock() + view.post(req, pk=1) + kwargs = mock_mapping_cls.call_args.kwargs + assert kwargs["is_regex"] is False + assert kwargs["librenms_name"] == "Sfm 1" + + def test_post_skips_when_existing_regex_covers(self): + view, req, device, target, _m = self._post_helper( + { + "target_kind": "device_type", + "target_pk": "7", + "name": "SFM 1", + "librenms_name": "Sfm 1", + "librenms_class": "fabricModule", + "also_create_mapping": "1", + "mapping_kind": "regex", + } + ) + existing = MagicMock() + existing.librenms_name = r"^Sfm (\d+)$" + with ( + patch("netbox_librenms_plugin.views.sync.modules.get_object_or_404", side_effect=[device, target]), + 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"), + patch("netbox_librenms_plugin.views.sync.modules._modules_redirect_response", return_value="R"), + patch("dcim.models.ModuleBayTemplate") as mock_bt_cls, + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls, + ): + mock_tx.atomic.return_value.__enter__ = lambda s: s + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + mock_bt_cls.return_value = MagicMock() + mock_mapping_cls.objects.filter.return_value.filter.return_value.exists.return_value = False + mock_mapping_cls.objects.filter.return_value.filter.return_value.only.return_value = [existing] + view.post(req, pk=1) + # Coverage already exists β†’ no new ModuleBayMapping instantiated. + mock_mapping_cls.assert_not_called() + + """_render_fix_bay_template_badge emits an HTMX modal trigger when device + target_pk known.""" + + def _table_with_device(self, device_pk=99, can_add_module_bay_template=True): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = object.__new__(LibreNMSModuleTable) + table.device = MagicMock(pk=device_pk) + table.can_add_module_bay_template = can_add_module_bay_template + return table + + def _table_without_device(self): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = object.__new__(LibreNMSModuleTable) + # Intentionally unset device β€” _render_fix_bay_template_badge handles via getattr. + return table + + def test_renders_htmx_button_when_device_and_target_pk_present(self): + table = self._table_with_device(device_pk=42) + with patch( + "netbox_librenms_plugin.tables.modules.reverse", + return_value="/plugins/librenms_plugin/devices/42/add-bay-template/", + ): + html = str( + table._render_fix_bay_template_badge( + title="some title", + target_kind="module_type", + target_pk=5, + target_label="A9K", + suggestion={"name": "Slot 1", "position": "1", "label": "Line"}, + fallback_url="/dcim/module-types/5/", + label="Fix Model", + ) + ) + assert "hx-get=" in html + assert "target_kind=module_type" in html + assert "target_pk=5" in html + assert "suggested_name=Slot+1" in html or "suggested_name=Slot%201" in html + assert "Fix Model" in html + + def test_falls_back_to_link_when_device_missing(self): + table = self._table_without_device() + html = str( + table._render_fix_bay_template_badge( + title="t", + target_kind="device_type", + target_pk=7, + target_label="ASR", + suggestion={}, + fallback_url="/dcim/device-types/7/", + label="Fix Device Type", + ) + ) + assert "hx-get" not in html + assert '