diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3560a93af3..7024351758 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -98,6 +98,8 @@ Below are the dev container defaults. The field name to change these defaults is - Plugin loader: enabled; reads `.devcontainer/config/plugin-config.py` if present - If `plugin-config.py` is missing: plugin is enabled with empty config (features won’t work until configured) + + ## πŸ”§ Configuration ### NetBox Version and Environment (use .devcontainer/.env) diff --git a/.devcontainer/scripts/diagnose.sh b/.devcontainer/scripts/diagnose.sh index 133e6ca97f..be7596d699 100755 --- a/.devcontainer/scripts/diagnose.sh +++ b/.devcontainer/scripts/diagnose.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script echo "πŸ” DevContainer Startup Diagnostics" echo "==================================" diff --git a/.devcontainer/scripts/load-aliases.sh b/.devcontainer/scripts/load-aliases.sh index 65149d6198..feac6ee98f 100755 --- a/.devcontainer/scripts/load-aliases.sh +++ b/.devcontainer/scripts/load-aliases.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Quick alias loader for current session # Usage: source .devcontainer/scripts/load-aliases.sh diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 588fe652ab..7f4278fd46 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script set -e echo "πŸš€ Setting up NetBox LibreNMS Plugin development environment..." diff --git a/.devcontainer/scripts/start-netbox.sh b/.devcontainer/scripts/start-netbox.sh index 789dcb845a..d5e4796600 100755 --- a/.devcontainer/scripts/start-netbox.sh +++ b/.devcontainer/scripts/start-netbox.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Check if we should run in background or foreground BACKGROUND=false @@ -18,7 +19,6 @@ if [ "$CODESPACES" = "true" ] && [ -n "$CODESPACE_NAME" ]; then echo "πŸ”— GitHub Codespaces detected" else ACCESS_URL="http://localhost:8000" - echo "πŸ› Debug: ACCESS_URL is set to: $ACCESS_URL" fi # Load shared process management helpers diff --git a/.devcontainer/scripts/welcome.sh b/.devcontainer/scripts/welcome.sh index 9328d663aa..e273313766 100755 --- a/.devcontainer/scripts/welcome.sh +++ b/.devcontainer/scripts/welcome.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Ensure aliases are available in the postAttach terminal session source "$(dirname "$0")/load-aliases.sh" 2>/dev/null @@ -44,7 +45,7 @@ if [ -n "$CODESPACES" ]; then echo " πŸ’‘ Click the link in the Ports panel or look for the 'Open in Browser' button" else echo "πŸ–₯️ Local Development Environment:" - echo " NetBox will be available at: http://localhost:8000 (paste into you browser)" + echo " NetBox will be available at: http://localhost:8000 (paste into your browser)" fi echo "" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f6faee6938..5e142be68a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: github-actions: patterns: - "*" + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..99ff02374f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,49 @@ +## Summary +Briefly describe what this PR does in plain English, and provide as much of the following information as possible. + +## Motivation / Problem +What issue does this solve? +- Bug +- Feature +- Refactor +- Maintenance / cleanup + +Link any related issues if applicable. + +## Scope of Change +Delete items that don’t apply: + +- Sync/Import logic +- NetBox models / ORM +- LibreNMS API interaction +- Config / settings +- Web UI / templates +- Database migrations +- Tests +- Docs only +- Other: + +## How Was This Tested? +Delete items that don’t apply and describe briefly. + +- Unit tests: +- Manual testing: +- Not tested: + +### Manual Test Steps (if applicable) +1. +2. +3. + +## Risk Assessment +- Does this change affect existing users? +- Could this cause unintended imports / updates? + +Explain briefly. + +## Backwards Compatibility +- No breaking changes +- Breaking change (explain and document) + +## Other Notes +Anything the maintainer(s) should pay particular attention to? diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index 3e12242f63..055f809cc5 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -2,13 +2,7 @@ name: Lint and Format on: push: - branches: - - master - - develop pull_request: - branches: - - master - - develop jobs: format-and-lint: @@ -20,8 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.9' - cache: 'pip' + python-version: '3.12' - name: Install dependencies run: | @@ -29,22 +22,7 @@ jobs: pip install ruff - name: Run Ruff linting - run: | - echo "::group::Ruff Linting" - ruff check . --output-format=github - echo "::endgroup::" + run: ruff check . - name: Run Ruff formatting check - run: | - echo "::group::Ruff Formatting" - ruff format --check . - echo "::endgroup::" - - - name: Report formatting issues - if: failure() - run: | - echo "::error::Formatting or linting issues detected!" - echo "To fix locally, run:" - echo " ruff check --fix ." - echo " ruff format ." - echo "Then commit and push the changes." + run: ruff format --check . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ad4fa8640c..5605aa04fb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -59,7 +59,7 @@ jobs: working-directory: netbox-librenms-plugin run: | pip install -e . - pip install pytest pytest-django + pip install pytest pytest-django pytest-cov - name: Set up configuration working-directory: netbox @@ -75,4 +75,14 @@ jobs: env: NETBOX_CONFIGURATION: netbox.configuration run: | - python -m pytest ../../netbox-librenms-plugin/netbox_librenms_plugin/tests/ -v + python -m pytest ../../netbox-librenms-plugin/netbox_librenms_plugin/tests/ -v \ + --cov=netbox_librenms_plugin \ + --cov-report=html:../../netbox-librenms-plugin/coverage_html \ + --cov-report=term-missing + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: matrix.python-version == '3.12' + with: + name: coverage-report + path: netbox-librenms-plugin/coverage_html/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c51eb902da..acfaf7983b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.13 # Use the latest version from https://github.com/astral-sh/ruff-pre-commit/releases + rev: v0.15.4 # Use the latest version from https://github.com/astral-sh/ruff-pre-commit/releases hooks: # Run the linter - id: ruff-check @@ -14,5 +14,6 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + exclude: mkdocs\.yml$ - id: check-added-large-files - id: check-merge-conflict diff --git a/README.md b/README.md index 3f8961beff..80969d1723 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Alternatively, share your ideas for the plugin over in [discussions](https://git | NetBox Version | Plugin Version | |----------------|----------------| | 4.1 | 0.2.x - 0.3.5 | -| 4.2 - 4.4 | 0.3.6+ | +| 4.2 - 4.5 | 0.3.6+ | ## Installing diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000000..8714ac5342 --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,28 @@ +# 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 | + +## 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/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_name_rules.yaml b/contrib/interface_name_rules.yaml new file mode 100644 index 0000000000..52da69dff5 --- /dev/null +++ b/contrib/interface_name_rules.yaml @@ -0,0 +1,200 @@ +# Interface Name Rules +# +# Post-install interface rename rules for module types where NetBox's +# position-based naming can't produce the correct interface name. +# +# Covers two scenarios: +# 1. Converter offset β€” e.g., GLC-T inside CVR-X2-SFP needs port numbering +# that accounts for the converter's position in the parent module bay. +# 2. Breakout channels β€” e.g., QSFP+ 4x10G produces multiple sub-interfaces +# from a single physical port. +# +# Template variables: +# {slot} β€” Top-level slot/module bay position +# {bay_position} β€” Position of the bay this module is installed into (raw) +# {bay_position_num} β€” Numeric suffix of bay position (e.g., "swp1" β†’ "1") +# {parent_bay_position} β€” Position of the parent module's bay +# {sfp_slot} β€” Numeric sub-bay index within the parent module +# {base} β€” Original interface name from the NetBox module template +# {channel} β€” Breakout channel number (iterated) +# +# Arithmetic expressions are supported inside braces: +# {8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}} +# +# Bulk import via: LibreNMS Plugin > Settings > Interface Name Rules > Import + +# --- Converter Offset Examples --- + +# SFP-1G-T (1G copper SFP, covers GLC-T/GLC-TE) in CVR-X2-SFP converter +# X2 bays are numbered 1-N; each converter holds 2 SFP slots +# Resulting interface: GigabitEthernet/ +- module_type: SFP-1G-T + parent_module_type: CVR-X2-SFP + name_template: "GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}" + channel_count: 0 + channel_start: 0 + description: "SFP-1G-T in CVR-X2-SFP: offset port numbering for X2-to-SFP conversion" + +# --- Breakout Channel Examples --- + +# QSFP-4X10G-LR breakout β€” Juniper-style (channels start at 0) +- module_type: QSFP-4X10G-LR + name_template: "{base}:{channel}" + channel_count: 4 + channel_start: 0 + description: "QSFP+ 4x10G-LR breakout with Juniper-style channel numbering (0-3)" + +# QSFP-4X10G-SR breakout β€” Juniper-style (channels start at 0) +- module_type: QSFP-4X10G-SR + name_template: "{base}:{channel}" + channel_count: 4 + channel_start: 0 + description: "QSFP+ 4x10G-SR breakout with Juniper-style channel numbering (0-3)" + +# --- Commented Examples --- + +# QSFP+ 4x10G breakout β€” Cisco-style (channels start at 1) +# - module_type: QSFP-4X10G-LR +# name_template: "{base}:{channel}" +# channel_count: 4 +# channel_start: 1 +# description: "QSFP+ 4x10G breakout with Cisco-style channel numbering (1-4)" + +# --- UfiSpace/Arcos Breakout Rules --- +# UfiSpace switches use swpNsC naming for breakout interfaces. +# bay_position_num extracts the numeric suffix from the bay name (e.g., "swp1" β†’ "1"). +# Channels start at 1, with 2 channels per 100G QSFP28 (2x100G breakout). + +# S9610-36D breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# S9610-46DX breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# S9700-53DX breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# --- Juniper ACX7024 Platform-Specific Rules --- +# These rules are scoped to the ACX7024 device type and use bay_position +# to generate Juniper-style interface names with FPC/PIC/port notation. + +# 100GE QSFP28 transceivers -> et-0/0/{port} +- module_type: QSFP-100G-LR4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +- module_type: QSFP-100G-SR4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +- module_type: QSFP-100G-SWDM4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +# 10GE SFP+ transceivers -> xe-0/0/{port} +- module_type: SFP-10G-SR + device_type: ACX7024 + name_template: "xe-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 10GE SFP+ naming" + +- module_type: SFP-10G-LR + device_type: ACX7024 + name_template: "xe-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 10GE SFP+ naming" + +# 1GE SFP transceivers -> ge-0/0/{port} +- module_type: SFP-1G-T + device_type: ACX7024 + name_template: "ge-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 1GE SFP naming" + +- module_type: SFP-1G-LX + device_type: ACX7024 + name_template: "ge-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 1GE SFP naming" diff --git a/contrib/interface_type_mappings.yaml b/contrib/interface_type_mappings.yaml new file mode 100644 index 0000000000..19db2a1fcf --- /dev/null +++ b/contrib/interface_type_mappings.yaml @@ -0,0 +1,70 @@ +# 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 + +- 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/module_bay_mappings.yaml b/contrib/module_bay_mappings.yaml new file mode 100644 index 0000000000..64c063176a --- /dev/null +++ b/contrib/module_bay_mappings.yaml @@ -0,0 +1,216 @@ +# 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 +# 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 + description: "Juniper MX SFP+ @ fpc/pic/port β†’ Transceiver pic/port" + +# ─── 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..e70d726f1b --- /dev/null +++ b/contrib/module_type_mappings.yaml @@ -0,0 +1,332 @@ +# 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 +# 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: "QSFP28-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 ───────────────────────────────────── +# These are mapped based on port context (QSFP28 100G slot) when vendor is unknown. + +- librenms_model: "1F3QAA" + netbox_module_type: "QSFP-100G-LR4" + description: "Unknown QSFP28 100G (mapped by port context)" diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml new file mode 100644 index 0000000000..c3d2081bea --- /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 alnum + 2 quality-tier letters), +# discards the 2-letter revision code + 2-digit build number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w{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\\w+)\\s+.*$" + replacement: "\\1" + priority: 50 + description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)" diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index 032812ed82..7ed27a2f97 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -4,6 +4,9 @@ To enhance device identification and synchronization between NetBox and LibreNMS, this plugin supports using a custom field `librenms_id` on Device, Virtual Machine and Interface objects. While the plugin works without it, using this custom field is recommended for LibreNMS API lookups, and to assist with matching the remote device and remote interfaces for cable creation in Netbox. It can also be entered manually if no primary IP or FQDN is available. +!!! info "Automatic Creation" + As of version 0.4.2, the plugin **automatically creates** the `librenms_id` custom field when migrations are run. You no longer need to create it manually. The field is created for Device, Virtual Machine, Interface, and VM Interface objects. + For the Device and Virtual Machine objects the plugin will automatically populate the LibreNMS ID custom field when opening the LibreNMS Sync page if the device has been found in LibreNMS. For the Interface object, the plugin will automatically populate the LibreNMS ID custom field when the interface data is synced from LibreNMS. @@ -15,7 +18,10 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID - **Efficient Synchronization:** Enhances the reliability of API lookups. - **Cable creation:** Allows better device identification for the creation of cables between NetBox devices. -## Suggested Custom Field Setup +## Manual Custom Field Setup (Legacy) + +!!! note + This section is only needed if you are running an older version of the plugin that does not auto-create the field, or if you need to recreate it after deletion. Follow these steps to create the `librenms_id` custom field in NetBox: diff --git a/docs/usage_tips/permissions.md b/docs/usage_tips/permissions.md index 9f9ecfb3f3..39c5225d2a 100644 --- a/docs/usage_tips/permissions.md +++ b/docs/usage_tips/permissions.md @@ -26,7 +26,7 @@ A user needs both tiers of permissions to complete an action. For example, to vi The Plugin also enforces Netbox object permissions so the following permission would also be required: -2. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox) +1. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox) If either permission is missing, the operation fails with an appropriate error message. diff --git a/netbox_librenms_plugin/__init__.py b/netbox_librenms_plugin/__init__.py index 96b9997506..6b60735762 100644 --- a/netbox_librenms_plugin/__init__.py +++ b/netbox_librenms_plugin/__init__.py @@ -28,6 +28,7 @@ def ready(self): super().ready() from django.conf import settings + from django.db.models.signals import post_migrate plugin_config = getattr(settings, "PLUGINS_CONFIG", {}).get(self.name, {}) @@ -37,6 +38,12 @@ def ready(self): else: self._validate_legacy_config(plugin_config) + # Auto-create the librenms_id custom field after migrations complete + post_migrate.connect( + _ensure_librenms_id_custom_field, + dispatch_uid="netbox_librenms_plugin_ensure_cf", + ) + def _validate_multi_server_config(self, servers_config): """Validate multi-server configuration.""" if not servers_config or not isinstance(servers_config, dict): @@ -61,4 +68,72 @@ def _validate_legacy_config(self, plugin_config): ) +def _ensure_librenms_id_custom_field(sender, **kwargs): + """ + Auto-create (or migrate) the 'librenms_id' custom field. + Runs after migrations via post_migrate signal to ensure tables exist. + Uses dispatch_uid to avoid duplicate connections. + + librenms_id stores a per-server JSON mapping {"server_key": device_id}. + Legacy installations may have this field typed as 'integer'; we upgrade it + to 'json' automatically so the UI and API accept the dict format. + """ + # Only run once per migrate invocation (post_migrate fires per-app). + if getattr(_ensure_librenms_id_custom_field, "_executed", False): + return + + import logging + + try: + from django.contrib.contenttypes.models import ContentType + + from extras.models import CustomField + + cf, created = CustomField.objects.get_or_create( + name="librenms_id", + defaults={ + "type": "json", + "label": "LibreNMS ID", + "description": "LibreNMS Device ID for synchronization (auto-created by plugin)", + "required": False, + "ui_visible": "if-set", + "ui_editable": "yes", + "is_cloneable": False, + }, + ) + + # Migrate legacy integer-typed field to JSON so the multi-server + # dict format {"server_key": device_id} is accepted by the UI/API. + if not created and cf.type == "integer": + cf.type = "json" + cf.save(update_fields=["type"]) + logging.getLogger("netbox_librenms_plugin").info( + "Migrated 'librenms_id' custom field type from integer to json" + ) + + # Ensure the field is assigned to the required object types + from dcim.models import Device, Interface + from virtualization.models import VirtualMachine, VMInterface + + required_models = [Device, VirtualMachine, Interface, VMInterface] + current_types = set(cf.object_types.values_list("pk", flat=True)) + + for model in required_models: + ct = ContentType.objects.get_for_model(model) + if ct.pk not in current_types: + cf.object_types.add(ct) + + if created: + logging.getLogger("netbox_librenms_plugin").info( + "Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface" + ) + + # Only mark as executed after successful completion to allow retry on failure. + _ensure_librenms_id_custom_field._executed = True + except Exception as e: + # Don't break startup if custom field creation fails (e.g., during initial migration), + # but log the error so it's not silently swallowed. + logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e) + + config = LibreNMSSyncConfig diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef20..bcde788d2b 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -1,6 +1,12 @@ from netbox.api.serializers import NetBoxModelSerializer -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) class InterfaceTypeMappingSerializer(NetBoxModelSerializer): @@ -11,3 +17,51 @@ 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", "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", "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", + ] diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index 230aa078d0..c032e7b2f5 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -7,6 +7,10 @@ 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) 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 768c67f5fe..287a3858c1 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -11,9 +11,21 @@ from rq.job import Job as RQJob from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN -from netbox_librenms_plugin.models import InterfaceTypeMapping - -from .serializers import InterfaceTypeMappingSerializer +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) + +from .serializers import ( + DeviceTypeMappingSerializer, + InterfaceTypeMappingSerializer, + ModuleBayMappingSerializer, + ModuleTypeMappingSerializer, + NormalizationRuleSerializer, +) logger = logging.getLogger(__name__) @@ -22,8 +34,8 @@ class LibreNMSPluginPermission(BasePermission): """ Permission class for LibreNMS plugin API endpoints. - - GET requests require view_librenmssettings - - All other requests require change_librenmssettings + - Safe requests (GET, HEAD, OPTIONS) require netbox_librenms_plugin.view_librenmssettings + - All other requests require netbox_librenms_plugin.change_librenmssettings """ def has_permission(self, request, view): @@ -41,6 +53,42 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet): serializer_class = InterfaceTypeMappingSerializer +class DeviceTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for DeviceTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = DeviceTypeMapping.objects.all() + serializer_class = DeviceTypeMappingSerializer + + +class ModuleTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleTypeMapping.objects.all() + serializer_class = ModuleTypeMappingSerializer + + +class ModuleBayMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleBayMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleBayMapping.objects.all() + serializer_class = ModuleBayMappingSerializer + + +class NormalizationRuleViewSet(NetBoxModelViewSet): + """API viewset for NormalizationRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = NormalizationRule.objects.all() + serializer_class = NormalizationRuleSerializer + + @api_view(["POST"]) @permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64c..134bd8962d 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -1,6 +1,6 @@ import django_filters -from .models import InterfaceTypeMapping +from .models import DeviceTypeMapping, InterfaceTypeMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule class InterfaceTypeMappingFilterSet(django_filters.FilterSet): @@ -11,3 +11,43 @@ class Meta: model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for DeviceTypeMapping model.""" + + class Meta: + """Meta options for DeviceTypeMappingFilterSet.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "description"] + + +class ModuleTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleTypeMapping model.""" + + class Meta: + """Meta options for ModuleTypeMappingFilterSet.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "description"] + + +class ModuleBayMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleBayMapping model.""" + + class Meta: + """Meta options for ModuleBayMappingFilterSet.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex"] + + +class NormalizationRuleFilterSet(django_filters.FilterSet): + """Filter set for NormalizationRule model.""" + + class Meta: + """Meta options for NormalizationRuleFilterSet.""" + + model = NormalizationRule + fields = ["scope", "manufacturer"] diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index f3e3d075e5..9c0bc7d9dc 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -2,7 +2,7 @@ import logging 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, Rack, Site from django import forms from django.http import QueryDict from django.utils.translation import gettext_lazy as _ @@ -12,10 +12,22 @@ 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 ( + DeviceTypeMapping, + InterfaceTypeMapping, + LibreNMSSettings, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) logger = logging.getLogger(__name__) @@ -51,11 +63,24 @@ def _get_librenms_poller_group_choices(): """ Helper function to get poller group choices from LibreNMS API. Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms. + 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() + server_id = api.librenms_url.rstrip("/") + cache_key = f"librenms_poller_group_choices_{server_id}" + except Exception: + cache_key = "librenms_poller_group_choices" + cached_choices = cache.get(cache_key) + if cached_choices: + return cached_choices + try: api = LibreNMSAPI() success, poller_groups = api.get_poller_groups() @@ -72,6 +97,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") @@ -90,10 +117,13 @@ class ServerConfigForm(NetBoxModelForm): ) class Meta: + """Meta options for ServerConfigForm.""" + model = LibreNMSSettings fields = ["selected_server"] def __init__(self, *args, **kwargs): + """Initialize form and populate server choices.""" super().__init__(*args, **kwargs) self.fields["selected_server"].choices = _get_librenms_server_choices() @@ -131,6 +161,8 @@ class ImportSettingsForm(NetBoxModelForm): ) class Meta: + """Meta options for ImportSettingsForm.""" + model = LibreNMSSettings fields = [ "vc_member_name_pattern", @@ -213,6 +245,8 @@ class InterfaceTypeMappingForm(NetBoxModelForm): """ class Meta: + """Meta options for InterfaceTypeMappingForm.""" + model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] @@ -230,6 +264,8 @@ class InterfaceTypeMappingImportForm(NetBoxModelImportForm): ) class Meta: + """Meta options for InterfaceTypeMappingImportForm.""" + model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] @@ -260,6 +296,175 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm): model = InterfaceTypeMapping +class DeviceTypeMappingForm(NetBoxModelForm): + """Form for creating and editing device type mappings between LibreNMS and NetBox.""" + + netbox_device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + label="NetBox Device Type", + widget=forms.Select(attrs={"class": "form-select"}), + ) + + 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.""" + + 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", "netbox_device_type", "description"] + + +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.""" + + class Meta: + """Meta options for ModuleTypeMappingForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "netbox_module_type", "description"] + + +class ModuleTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module type mappings.""" + + 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", "netbox_module_type", "description"] + + +class ModuleTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module type mappings.""" + + librenms_model = forms.CharField(required=False, label="LibreNMS Model") + 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.""" + + class Meta: + """Meta options for ModuleBayMappingForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module bay mappings.""" + + class Meta: + """Meta options for ModuleBayMappingImportForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "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, label="Regex") + + model = ModuleBayMapping + + +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 AddToLIbreSNMPV1V2(forms.Form): """ Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. @@ -315,6 +520,7 @@ class AddToLIbreSNMPV1V2(forms.Form): ) def __init__(self, *args, **kwargs): + """Initialize form and populate poller group choices.""" super().__init__(*args, **kwargs) self.fields["poller_group"].choices = _get_librenms_poller_group_choices() @@ -412,6 +618,7 @@ class AddToLIbreSNMPV3(forms.Form): ) def __init__(self, *args, **kwargs): + """Initialize form and populate poller group choices.""" super().__init__(*args, **kwargs) self.fields["poller_group"].choices = _get_librenms_poller_group_choices() @@ -422,6 +629,7 @@ class DeviceStatusFilterForm(NetBoxModelFilterSetForm): """ def __init__(self, *args, **kwargs): + """Initialize form and remove saved filter field.""" super().__init__(*args, **kwargs) # Remove the saved filter field if it exists if "filter_id" in self.fields: @@ -581,7 +789,8 @@ def _populate_librenms_locations(self): try: # Use caching to avoid repeated API calls - cache_key = "librenms_locations_choices" + api = LibreNMSAPI() + cache_key = f"librenms_locations_choices:{api.server_key}" cached_choices = cache.get(cache_key) if cached_choices: @@ -589,7 +798,6 @@ def _populate_librenms_locations(self): return # Fetch locations from LibreNMS - api = LibreNMSAPI() success, locations = api.get_locations() if success and locations: diff --git a/netbox_librenms_plugin/import_utils/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py index b7a08fef59..81c24025ea 100644 --- a/netbox_librenms_plugin/import_utils/__init__.py +++ b/netbox_librenms_plugin/import_utils/__init__.py @@ -23,6 +23,7 @@ get_active_cached_searches, get_cache_metadata_key, get_import_device_cache_key, + get_import_search_cache_key, get_validated_device_cache_key, ) from .device_operations import ( # noqa: F401 diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 5e98347efe..92729413df 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -1,4 +1,4 @@ -"""Bulk import orchestration and filter processing.""" +"""Bulk import orchestration for devices and filter processing.""" import logging from typing import List @@ -7,6 +7,7 @@ from django.core.cache import cache from ..librenms_api import LibreNMSAPI +from ..utils import find_by_librenms_id from .cache import get_cache_metadata_key, get_import_device_cache_key, get_validated_device_cache_key from .device_operations import import_single_device, validate_device_for_import from .filters import get_librenms_devices_for_import @@ -20,6 +21,28 @@ logger = logging.getLogger(__name__) +def _safe_disabled(device: dict) -> int: + """Return 1 if the device is disabled, 0 otherwise. + + Handles None, booleans, numeric strings, and common truthy/falsy tokens + (e.g. "true"/"yes"/"on" β†’ 1, "false"/"no"/"off" β†’ 0) without raising. + """ + val = device.get("disabled", 0) + if isinstance(val, bool): + return int(val) + if isinstance(val, str): + normalized = val.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return 1 + if normalized in ("0", "false", "no", "off", ""): + return 0 + try: + int_val = int(val) + return 1 if int_val else 0 + except (TypeError, ValueError): + return 0 + + def bulk_import_devices_shared( device_ids: List[int], server_key: str = None, @@ -89,21 +112,34 @@ def bulk_import_devices_shared( api = LibreNMSAPI(server_key=server_key) for idx, device_id in enumerate(device_ids, start=1): - # Check for job cancellation every 5 devices - if job and idx % 5 == 0: - # Refresh job from DB to get current status - job.job.refresh_from_db() - job_status = job.job.status - status_value = job_status.value if hasattr(job_status, "value") else job_status - if status_value in (JobStatusChoices.STATUS_FAILED, "failed", "errored"): - if job.logger: - job.logger.warning(f"Import job cancelled at device {idx} of {total}") - else: - logger.warning(f"Import cancelled at device {idx} of {total}") - break - # Log progress - if job.logger: - job.logger.info(f"Imported device {idx} of {total}") + # Check for job cancellation on first iteration and every 5th thereafter. + # Check RQ/Redis state first (reflects stop API immediately); fall back to DB. + if job and (idx == 1 or idx % 5 == 0): + try: + from django_rq import get_queue + from rq.job import Job as RQJob + + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + if rq_job.is_failed or rq_job.is_stopped: + if job.logger: + job.logger.warning( + f"Import job stopped at device {idx} of {total} (RQ status: {rq_job.get_status()})" + ) + else: + logger.warning(f"Import cancelled at device {idx} of {total}") + break + except Exception: + # Fall back to DB check if RQ is unavailable + job.job.refresh_from_db() + job_status = job.job.status + status_value = job_status.value if hasattr(job_status, "value") else job_status + if status_value in (JobStatusChoices.STATUS_FAILED, "failed", "errored"): + if job.logger: + job.logger.warning(f"Import job cancelled at device {idx} of {total}") + else: + logger.warning(f"Import cancelled at device {idx} of {total}") + break try: # Use cached device data if available to avoid redundant API calls @@ -129,6 +165,7 @@ def bulk_import_devices_shared( api=api, use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, + server_key=api.server_key, ) # Build manual mappings from validation + any provided overrides @@ -148,7 +185,7 @@ def bulk_import_devices_shared( result = import_single_device( device_id, - server_key=server_key, + server_key=api.server_key, # use resolved key, not raw parameter (may be None) sync_options=sync_options, manual_mappings=device_mappings if device_mappings else None, libre_device=libre_device, @@ -162,11 +199,26 @@ def bulk_import_devices_shared( "message": result["message"], } ) + # Log progress after each successful import + if job and job.logger: + job.logger.info(f"Imported device {idx} of {total}") # Handle virtual chassis creation for stacks vc_data = validation.get("virtual_chassis", {}) if vc_data.get("is_stack", False): - vc_domain = f"librenms-{device_id}" + # Derive a stack-level dedup key from member serials so that all + # LibreNMS devices belonging to the same physical stack (e.g. each + # switch in a stacked chassis that appears as a separate device in + # LibreNMS) share the same key and VC creation is triggered only once. + # Fall back to device_id when no member serials are available. + member_serials = sorted( + serial + for m in vc_data.get("members", []) + if (serial := str(m.get("serial") or "").strip()) and serial != "-" + ) + vc_domain = ( + f"librenms-stack-{','.join(member_serials)}" if member_serials else f"librenms-{device_id}" + ) # Only create VC if we haven't processed this stack yet # Add to set BEFORE attempting creation to prevent race condition @@ -266,45 +318,106 @@ def bulk_import_devices( ) -def _refresh_existing_device(validation: dict) -> None: - """Refresh existing_device from DB to pick up changes made in NetBox since caching.""" +def _refresh_existing_device(validation: dict, libre_device: dict = None, server_key: str = "default") -> None: + """Refresh existing_device from DB to pick up changes made in NetBox since caching. + + When existing_device is None (wasn't found at cache time), re-check if the device + was imported since caching by looking up librenms_id or hostname. + """ existing = validation.get("existing_device") - if not existing or not hasattr(existing, "pk"): + if existing and hasattr(existing, "pk"): + try: + from dcim.models import Device + from virtualization.models import VirtualMachine + + if validation.get("import_as_vm"): + refreshed = VirtualMachine.objects.filter(pk=existing.pk).first() + else: + refreshed = Device.objects.filter(pk=existing.pk).first() + + if refreshed: + validation["existing_device"] = refreshed + if hasattr(refreshed, "role") and refreshed.role: + validation.setdefault("device_role", {}).update({"found": True, "role": refreshed.role}) + else: + # Device was deleted since caching β€” recompute readiness to match + # validate_device_for_import logic. + validation["existing_device"] = None + validation["existing_match_type"] = None + can_import = not bool(validation.get("issues")) + if validation.get("import_as_vm"): + # VMs only require a cluster (site/role not mandatory) + is_ready = can_import and bool(validation.get("cluster", {}).get("found")) + else: + is_ready = ( + can_import + and bool(validation.get("site", {}).get("found")) + and bool(validation.get("device_type", {}).get("found")) + and bool(validation.get("device_role", {}).get("found")) + ) + validation["can_import"] = can_import + validation["is_ready"] = is_ready + except Exception as e: + existing_id = getattr(existing, "pk", "unknown") if existing else "none" + logger.error(f"Failed to refresh existing device (pk={existing_id}): {e}") + return + + # existing_device was None at cache time β€” check if device was imported since + if not libre_device: return try: from dcim.models import Device from virtualization.models import VirtualMachine - if validation.get("import_as_vm"): - refreshed = VirtualMachine.objects.filter(pk=existing.pk).first() - else: - refreshed = Device.objects.filter(pk=existing.pk).first() - - if refreshed: - validation["existing_device"] = refreshed - if hasattr(refreshed, "role") and refreshed.role: - validation["device_role"]["found"] = True - validation["device_role"]["role"] = refreshed.role - else: - # Device was deleted since caching β€” recompute readiness - validation["existing_device"] = None - validation["existing_match_type"] = None - if validation.get("import_as_vm"): - required_found = ( - validation.get("site", {}).get("found") - and validation.get("cluster", {}).get("found") - and validation.get("device_role", {}).get("found") - ) - else: - required_found = ( - validation.get("site", {}).get("found") - and validation.get("device_type", {}).get("found") - and validation.get("device_role", {}).get("found") - ) - validation["can_import"] = validation["is_ready"] = bool(required_found and not validation.get("issues")) + import_as_vm = validation.get("import_as_vm", False) + Model = VirtualMachine if import_as_vm else Device + + librenms_id = libre_device.get("device_id") + hostname = libre_device.get("hostname", "") + sys_name = libre_device.get("sysName", "") + + new_device = None + match_type = None + + # Check by librenms_id custom field first (JSON multi-server format + legacy) + if librenms_id: + try: + new_device = find_by_librenms_id(Model, int(librenms_id), server_key) + if new_device: + match_type = "librenms_id" + except (ValueError, TypeError): + pass + + # Fall back to resolved_name first (accounts for use_sysname/strip_domain naming options) + resolved_name = validation.get("resolved_name") + if not new_device and resolved_name: + new_device = Model.objects.filter(name__iexact=resolved_name).first() + if new_device: + match_type = "resolved_name" + # Fall back to hostname match, then sys_name independently + if not new_device and hostname: + new_device = Model.objects.filter(name__iexact=hostname).first() + if new_device: + match_type = "hostname" + if not new_device and sys_name: + new_device = Model.objects.filter(name__iexact=sys_name).first() + if new_device: + match_type = "sysname" + + if new_device: + validation["existing_device"] = new_device + validation["existing_match_type"] = match_type + validation["can_import"] = False + validation["is_ready"] = False + if not import_as_vm and hasattr(new_device, "role") and new_device.role: + validation["device_role"] = {"found": True, "role": new_device.role} except Exception as e: - existing_id = getattr(existing, "pk", "unknown") if existing else "none" - logger.error(f"Failed to refresh existing device (pk={existing_id}): {e}") + logger.error(f"Failed to check for newly imported device: {e}") + + +def _empty_return(return_cache_status: bool): + """Centralised empty-result return value for process_device_filters.""" + return ([], False) if return_cache_status else [] def process_device_filters( @@ -338,7 +451,7 @@ def process_device_filters( request: Optional Django request for client disconnect detection (synchronous only) return_cache_status: When True, returns (devices, from_cache) tuple use_sysname: If True, prefer sysName over hostname for device name resolution - strip_domain: If True, strip domain suffix from device names + strip_domain: If True, strip domain suffix from device name Returns: List[dict]: Validated devices with _validation key, or tuple of (devices, from_cache) @@ -360,9 +473,11 @@ def process_device_filters( return_cache_status=True, ) - # Filter out disabled devices if requested + # Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled, + # 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability. + # show_disabled controls the former: hidden when disabled==1, shown regardless of status. if not show_disabled: - libre_devices = [d for d in libre_devices if d.get("status") == 1] + libre_devices = [d for d in libre_devices if _safe_disabled(d) != 1] if job: job.logger.info(f"Found {len(libre_devices)} devices to process") @@ -386,7 +501,7 @@ def process_device_filters( except (BrokenPipeError, ConnectionError, IOError) as e: if request: logger.info(f"Client disconnected during VC prefetch: {e}") - return [] + return _empty_return(return_cache_status) raise # Validate each device @@ -406,13 +521,13 @@ def process_device_filters( if rq_job.is_failed or rq_job.is_stopped: job.logger.warning("Job was already stopped before validation started") - return [] + return _empty_return(return_cache_status) except Exception: # Fall back to DB check if RQ check fails job.job.refresh_from_db() - if job.job.status == JobStatusChoices.STATUS_FAILED: + if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): job.logger.warning("Job was stopped before validation started") - return [] + return _empty_return(return_cache_status) else: logger.info(f"Validating {total} devices") @@ -435,21 +550,13 @@ def process_device_filters( job.logger.info( f"Job stopped at device {idx}/{total} (RQ status: {rq_job.get_status()}). Exiting gracefully." ) - return [] + return _empty_return(return_cache_status) except Exception: # If we can't check RQ status, fall back to DB status check job.job.refresh_from_db() - if job.job.status == JobStatusChoices.STATUS_FAILED: + if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.") - return [] - elif request: - # Check for client disconnect - try: - if hasattr(request, "META") and request.META.get("wsgi.input"): - pass - except (BrokenPipeError, ConnectionError, IOError): - logger.info(f"Client disconnected during validation at device {idx}") - return [] + return _empty_return(return_cache_status) # Drop any cached validation/meta keys before recomputing device.pop("_validation", None) @@ -461,6 +568,8 @@ def process_device_filters( filters=filters, device_id=device_id, vc_enabled=vc_detection_enabled, + use_sysname=use_sysname, + strip_domain=strip_domain, ) # Check if we already have cached validation for this device @@ -473,7 +582,7 @@ def process_device_filters( # Refresh existing_device from DB to avoid stale data # (user may have changed role, name, etc. in NetBox) - _refresh_existing_device(device["_validation"]) + _refresh_existing_device(device["_validation"], libre_device=device, server_key=api.server_key) # Apply exclude_existing filter if enabled if exclude_existing: @@ -491,13 +600,14 @@ def process_device_filters( api=api_for_validation, include_vc_detection=vc_detection_enabled, force_vc_refresh=clear_cache, + server_key=api.server_key, use_sysname=use_sysname, strip_domain=strip_domain, ) except (BrokenPipeError, ConnectionError, IOError) as e: if request: logger.info(f"Client disconnected during device validation: {e}") - return [] + return _empty_return(return_cache_status) raise # Set VC detection metadata @@ -531,7 +641,11 @@ def process_device_filters( from datetime import datetime, timezone cache_metadata_key = get_cache_metadata_key( - server_key=api.server_key, filters=filters, vc_enabled=vc_detection_enabled + server_key=api.server_key, + filters=filters, + vc_enabled=vc_detection_enabled, + use_sysname=use_sysname, + strip_domain=strip_domain, ) # Check if metadata already exists to preserve original timestamp diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py index fe0896a302..119bb111b2 100644 --- a/netbox_librenms_plugin/import_utils/cache.py +++ b/netbox_librenms_plugin/import_utils/cache.py @@ -1,5 +1,7 @@ -"""Cache key generation and search management for device import operations.""" +"""Cache key generation and management for device import operations.""" +import hashlib +import json import logging from django.core.cache import cache @@ -7,7 +9,9 @@ logger = logging.getLogger(__name__) -def get_cache_metadata_key(server_key: str, filters: dict, vc_enabled: bool) -> str: +def get_cache_metadata_key( + server_key: str, filters: dict, vc_enabled: bool, use_sysname: bool = True, strip_domain: bool = False +) -> str: """ Generate a consistent cache metadata key from filter parameters. @@ -15,13 +19,16 @@ def get_cache_metadata_key(server_key: str, filters: dict, vc_enabled: bool) -> server_key: LibreNMS server identifier filters: Filter dictionary vc_enabled: Whether VC detection is enabled + use_sysname: Whether sysName is preferred over hostname for device naming + strip_domain: Whether domain suffix is stripped from device names Returns: str: Consistent cache key for metadata """ - # Sort filter items to ensure consistent key generation - filter_parts = "_".join(f"{k}={v}" for k, v in sorted(filters.items()) if v) - return f"librenms_filter_cache_metadata_{server_key}_{filter_parts}_{vc_enabled}" + # Sort filter items to ensure consistent key generation; use "is not None" to preserve + # valid falsy values like 0 and False (filtering only None/missing entries). + filter_parts = "_".join(f"{k}={v}" for k, v in sorted(filters.items()) if v is not None) + return f"librenms_filter_cache_metadata_{server_key}_{filter_parts}_{vc_enabled}_sysname={use_sysname}_strip={strip_domain}" def get_active_cached_searches(server_key: str) -> list[dict]: @@ -61,8 +68,9 @@ def get_active_cached_searches(server_key: str) -> list[dict]: "other": "Other", } - # Get cached location choices for enrichment - location_cache_key = "librenms_locations_choices" + # Get cached location choices for enrichment; scoped by server_key so labels + # from different LibreNMS servers don't bleed into each other's filter summaries. + location_cache_key = f"librenms_locations_choices:{server_key}" cached_locations = cache.get(location_cache_key) if cached_locations: location_choices = dict(cached_locations) @@ -71,9 +79,18 @@ def get_active_cached_searches(server_key: str) -> list[dict]: metadata = cache.get(cache_key) if metadata: # Cache still exists, calculate time remaining - cached_at = datetime.fromisoformat(metadata.get("cached_at")) cache_timeout = metadata.get("cache_timeout", 300) now = datetime.now(timezone.utc) + try: + cached_at_raw = metadata.get("cached_at") + cached_at = ( + datetime.fromisoformat(cached_at_raw) if cached_at_raw else datetime.fromtimestamp(0, timezone.utc) + ) + # Normalize naive datetimes (e.g., stored without tzinfo) to UTC + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + cached_at = datetime.fromtimestamp(0, timezone.utc) age_seconds = (now - cached_at).total_seconds() remaining_seconds = max(0, cache_timeout - age_seconds) @@ -109,7 +126,14 @@ def get_active_cached_searches(server_key: str) -> list[dict]: return active_searches -def get_validated_device_cache_key(server_key: str, filters: dict, device_id: int | str, vc_enabled: bool) -> str: +def get_validated_device_cache_key( + server_key: str, + filters: dict, + device_id: int | str, + vc_enabled: bool, + use_sysname: bool = True, + strip_domain: bool = False, +) -> str: """ Generate a consistent cache key for validated device data. @@ -121,6 +145,8 @@ def get_validated_device_cache_key(server_key: str, filters: dict, device_id: in filters: Filter dict with location, type, os, hostname, sysname, hardware keys device_id: LibreNMS device ID vc_enabled: Whether virtual chassis detection was enabled + use_sysname: Whether sysName is preferred over hostname for device naming + strip_domain: Whether domain suffix is stripped from device names Returns: str: Cache key for the validated device @@ -128,12 +154,14 @@ def get_validated_device_cache_key(server_key: str, filters: dict, device_id: in Example: >>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True) >>> key - 'validated_device_default_-1234567890_123_vc' + 'validated_device_default_e3b0c44298fc1c14_123_vc' """ - # Sort filters for consistent hashing - filter_hash = hash(str(sorted(filters.items()))) + # Sort filters for a deterministic, cross-process stable hash + filter_hash = hashlib.sha256(json.dumps(sorted(filters.items()), sort_keys=True).encode()).hexdigest()[:16] vc_part = "vc" if vc_enabled else "novc" - return f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}" + return ( + f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}_sysname={use_sysname}_strip={strip_domain}" + ) def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str: @@ -156,3 +184,29 @@ def get_import_device_cache_key(device_id: int | str, server_key: str = "default 'import_device_data_production_123' """ return f"import_device_data_{server_key}_{device_id}" + + +def get_import_search_cache_key(server_key: str, api_filters: dict, client_filters: dict) -> str: + """ + Generate a deterministic cache key for a LibreNMS device search result. + + The key encodes the server, API-side filters, and client-side filters so + that different filter combinations produce distinct cache entries. + + Args: + server_key: Resolved LibreNMS server key (use ``api.server_key``). + api_filters: Filters forwarded to the LibreNMS API. + client_filters: Filters applied client-side after the API response. + + Returns: + str: Cache key for the import search result. + """ + import hashlib + import json + + def _hash(d): + return hashlib.sha256( + json.dumps(sorted(d.items()) if isinstance(d, dict) else d, sort_keys=True).encode() + ).hexdigest()[:16] + + return f"librenms_devices_import_{server_key}_{_hash(api_filters)}_{_hash(client_filters)}" diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 68c221eacf..547a89e7c0 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -1,18 +1,21 @@ -"""Device validation, import, and matching operations.""" +"""Device validation, import, and fetch operations.""" import logging +from types import SimpleNamespace from dcim.models import Device, DeviceRole, DeviceType, Rack, Site from django.core.cache import cache from django.db import transaction +from django.db.models import Q from django.utils import timezone -from virtualization.models import Cluster +from virtualization.models import Cluster # noqa: F401 β€” used by test mock.patch targets from ..librenms_api import LibreNMSAPI from ..utils import ( find_matching_platform, find_matching_site, match_librenms_hardware_to_device_type, + set_librenms_device_id, ) from .cache import get_import_device_cache_key from .virtual_chassis import ( @@ -25,6 +28,44 @@ logger = logging.getLogger(__name__) +def _try_chassis_device_type_match(api, device_id): + """ + Attempt device type matching using chassis inventory fields. + + When the LibreNMS hardware string doesn't match any NetBox device type, + the chassis entity often contains a more standardized identifier + (e.g., entPhysicalName 'CHAS-BP-MX480-S' or entPhysicalModelName '710-017414') + that matches a DeviceType part_number or model. + + Tries entPhysicalName first (typically the chassis part number), + then entPhysicalModelName as fallback. + + Returns: + dict with matched/device_type/match_type keys, or None on failure. + """ + skip_values = {"", "-", "Unspecified", "BUILTIN", "None"} + + try: + success, inventory = api.get_inventory_filtered(device_id, ent_physical_class="chassis") + if not success or not inventory: + return None + + for item in inventory: + # Try entPhysicalName first (often the chassis part number like CHAS-BP-MX480-S) + for field in ("entPhysicalName", "entPhysicalModelName"): + value = item.get(field) or "" + if value and value not in skip_values: + chassis_match = match_librenms_hardware_to_device_type(value) + if chassis_match["matched"]: + chassis_match["match_type"] = "chassis" + chassis_match["chassis_model"] = value + return chassis_match + except Exception: + logger.debug(f"Chassis inventory fallback failed for device {device_id}", exc_info=True) + + return None + + def _determine_device_name( libre_device: dict, use_sysname: bool = True, @@ -90,6 +131,7 @@ def validate_device_for_import( force_vc_refresh: bool = False, use_sysname: bool = True, strip_domain: bool = False, + server_key: str = "default", ) -> dict: """ Validate if a LibreNMS device can be imported to NetBox. @@ -164,6 +206,7 @@ def validate_device_for_import( "serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs" "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 librenms_id is still a legacy bare int "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 @@ -199,6 +242,7 @@ def validate_device_for_import( "rack": None, "available_racks": [], }, + "naming_criteria": None, # Populated after resolved_name is set } try: @@ -212,12 +256,18 @@ def validate_device_for_import( device_id=librenms_id, ) result["resolved_name"] = hostname + _raw_sysname = libre_device.get("sysName") or "" + _raw_hostname = libre_device.get("hostname") or "" + if use_sysname: + _source = "sysname" if _raw_sysname else "hostname" + else: + _source = "hostname" if _raw_hostname else ("sysname" if _raw_sysname else "hostname") result["naming_criteria"] = { "use_sysname": use_sysname, "strip_domain": strip_domain, - "raw_sysname": libre_device.get("sysName") or "", - "raw_hostname": libre_device.get("hostname") or "", - "source": "sysName" if use_sysname else "hostname", + "raw_sysname": _raw_sysname, + "raw_hostname": _raw_hostname, + "source": _source, } logger.debug( f"Checking for existing device/VM: " @@ -228,9 +278,10 @@ def validate_device_for_import( from virtualization.models import VirtualMachine # Check for existing VM first (by librenms_id custom field) - # Always query with int to match custom field type try: - existing_vm = VirtualMachine.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first() + from netbox_librenms_plugin.utils import find_by_librenms_id + + existing_vm = find_by_librenms_id(VirtualMachine, int(librenms_id), server_key) except (ValueError, TypeError): # librenms_id is not convertible to int; no match will be found existing_vm = None @@ -242,7 +293,14 @@ def validate_device_for_import( result["import_as_vm"] = True # Force VM mode since VM exists result["can_import"] = False - # Check if name matches sysName + # Detect legacy bare-integer format so UI can offer a migration action. + # Direct access needed to detect legacy integer format for migration prompt: + # LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the + # raw type check on custom_field_data reveals whether migration is needed. + if isinstance(existing_vm.custom_field_data.get("librenms_id"), int): + result["librenms_id_needs_migration"] = True + + # Check if name matches resolved name (accounts for use_sysname/strip_domain) # Note: name_sync_available/suggested_name are intentionally not set for VMs # because UpdateDeviceNameView only supports Device objects; VM name-sync # would require a separate implementation. @@ -250,10 +308,11 @@ def validate_device_for_import( result["name_matches"] = True # Check for existing Device (by librenms_id custom field) - # Always query with int to match custom field type if not result["existing_device"]: try: - existing_device = Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first() + from netbox_librenms_plugin.utils import find_by_librenms_id + + existing_device = find_by_librenms_id(Device, int(librenms_id), server_key) except (ValueError, TypeError): # librenms_id is not convertible to int; no match will be found existing_device = None @@ -264,45 +323,30 @@ def validate_device_for_import( result["existing_match_type"] = "librenms_id" result["can_import"] = False - # Check if name matches resolved name (accounts for use_sysname/strip_domain) - # Also accounts for virtual chassis naming pattern when device is a VC member - name_matched = False - if hostname and existing_device.name == hostname: - name_matched = True - elif ( - hostname - and hasattr(existing_device, "virtual_chassis") - and existing_device.virtual_chassis is not None - and existing_device.vc_position is not None - ): - # Device is a VC member β€” generate the expected VC name using - # the same function that the import creation process uses - expected_vc_name = _generate_vc_member_name( + # Detect legacy bare-integer format so UI can offer a migration action. + # Direct access needed to detect legacy integer format for migration prompt: + # LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the + # raw type check on custom_field_data reveals whether migration is needed. + if isinstance(existing_device.custom_field_data.get("librenms_id"), int): + result["librenms_id_needs_migration"] = True + + # Check if name matches resolved name (VC-aware: compare against VC member name) + if hostname and existing_device.virtual_chassis and existing_device.vc_position: + vc_expected_name = _generate_vc_member_name( hostname, existing_device.vc_position, - serial=getattr(existing_device, "serial", None), + serial=existing_device.serial or "", ) - if existing_device.name == expected_vc_name: - name_matched = True - - if name_matched: + if existing_device.name == vc_expected_name: + result["name_matches"] = True + else: + result["name_sync_available"] = True + result["suggested_name"] = vc_expected_name + elif hostname and existing_device.name == hostname: result["name_matches"] = True - elif hostname: + elif hostname and existing_device.name != hostname: result["name_sync_available"] = True - # suggested_name uses resolved name (not raw sysName), - # respecting use_sysname/strip_domain preferences - if ( - hasattr(existing_device, "virtual_chassis") - and existing_device.virtual_chassis is not None - and existing_device.vc_position is not None - ): - result["suggested_name"] = _generate_vc_member_name( - hostname, - existing_device.vc_position, - serial=getattr(existing_device, "serial", None), - ) - else: - result["suggested_name"] = hostname + result["suggested_name"] = hostname # Check for serial drift on the linked device incoming_serial = libre_device.get("serial") or "" @@ -432,6 +476,10 @@ def validate_device_for_import( ) 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"] + # Validate based on import type (Device or VM) if import_as_vm: # 2. For VMs: Validate Cluster (required) - Must be manually selected @@ -468,9 +516,23 @@ def validate_device_for_import( # 3. Validate DeviceType (required) hardware = libre_device.get("hardware", "") dt_match = match_librenms_hardware_to_device_type(hardware) - result["device_type"] = dt_match + + # Chassis inventory fallback: when hardware doesn't match, + # try the chassis entPhysicalModelName as an additional lookup source + if not dt_match["matched"] and api: + device_id = libre_device.get("device_id") + if device_id: + chassis_match = _try_chassis_device_type_match(api, device_id) + if chassis_match and chassis_match["matched"]: + dt_match = chassis_match + + # Update result keys individually to preserve the existing schema (especially "found") + result["device_type"]["found"] = dt_match["matched"] + result["device_type"]["device_type"] = dt_match.get("device_type") + result["device_type"]["match_type"] = dt_match.get("match_type") if not dt_match["matched"]: + result["device_type"]["found"] = False result["issues"].append(f"No matching device type found for hardware: '{hardware}'") # Get some device types for user to choose from all_device_types = DeviceType.objects.all()[:10] @@ -482,11 +544,6 @@ def validate_device_for_import( } for dt in all_device_types ] - else: - # Rename 'matched' to 'found' for consistency - result["device_type"]["found"] = dt_match["matched"] - result["device_type"]["device_type"] = dt_match["device_type"] - result["device_type"]["match_type"] = dt_match["match_type"] # 4. DeviceRole (required) - Must be manually selected by user logger.debug(f"[{hostname}] Issues BEFORE adding role issue: {result['issues']}") @@ -511,9 +568,6 @@ def validate_device_for_import( available_racks = cache.get(cache_key) if available_racks is None: - from dcim.models import Rack - from django.db.models import Q - # Query racks for this site - include both: # 1. Racks assigned to locations within the site # 2. Racks directly assigned to the site (without location) @@ -559,20 +613,12 @@ def validate_device_for_import( ) if vc_detection: result["virtual_chassis"] = vc_detection - # Correct VC member suggested_names using the resolved name - # (which respects use_sysname/strip_domain preferences). - # This reuses the same function that BulkImportConfirmView uses. - if vc_detection.get("is_stack") and hostname: - update_vc_member_suggested_names(vc_detection, hostname) - logger.debug( - f"Virtual chassis CONFIRMED for device {hostname}: " - f"{vc_detection['member_count']} members" - ) - elif vc_detection["is_stack"]: + if vc_detection["is_stack"]: logger.debug( f"Virtual chassis CONFIRMED for device {hostname}: " f"{vc_detection['member_count']} members" ) + result["virtual_chassis"] = update_vc_member_suggested_names(vc_detection, hostname) except Exception as e: logger.exception(f"Exception during VC detection for device {hostname}: {e}") result["virtual_chassis"]["detection_error"] = str(e) @@ -694,6 +740,7 @@ def import_single_device( libre_device, use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, + server_key=api.server_key, ) # Check if device already exists @@ -772,6 +819,8 @@ def import_single_device( # Generate import timestamp comment import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z") + _cf_proxy = SimpleNamespace(custom_field_data={}) + set_librenms_device_id(_cf_proxy, device_id, api.server_key) device_data = { "name": device_name, "site": site, @@ -779,7 +828,7 @@ def import_single_device( "role": device_role, "status": "active" if libre_device.get("status") == 1 else "offline", "comments": f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}", - "custom_field_data": {"librenms_id": int(device_id)}, + "custom_field_data": _cf_proxy.custom_field_data, } # Add optional fields diff --git a/netbox_librenms_plugin/import_utils/filters.py b/netbox_librenms_plugin/import_utils/filters.py index 99aae78d5d..7b5c554589 100644 --- a/netbox_librenms_plugin/import_utils/filters.py +++ b/netbox_librenms_plugin/import_utils/filters.py @@ -1,15 +1,38 @@ -"""Device filtering and API queries for LibreNMS devices.""" +"""Device filtering and retrieval from LibreNMS.""" import logging from typing import List from django.core.cache import cache +from .cache import get_import_search_cache_key + from ..librenms_api import LibreNMSAPI logger = logging.getLogger(__name__) +def _safe_disabled(device: dict) -> int: + """Return 1 if the device is disabled, 0 otherwise. + + Handles None, booleans, numeric strings, and common truthy/falsy tokens + (e.g. "true"/"yes"/"on" β†’ 1, "false"/"no"/"off" β†’ 0) without raising. + """ + val = device.get("disabled", 0) + if isinstance(val, bool): + return int(val) + if isinstance(val, str): + normalized = val.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return 1 + if normalized in ("0", "false", "no", "off", ""): + return 0 + try: + return int(val) + except (TypeError, ValueError): + return 0 + + def get_device_count_for_filters( api: LibreNMSAPI, filters: dict, @@ -33,9 +56,11 @@ def get_device_count_for_filters( """ devices = get_librenms_devices_for_import(api, filters=filters, force_refresh=clear_cache) - # Filter out disabled devices if requested + # Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled, + # 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability. + # show_disabled controls the former: hidden when disabled==1, shown regardless of status. if not show_disabled: - devices = [d for d in devices if d.get("status") == 1] + devices = [d for d in devices if _safe_disabled(d) != 1] return len(devices) @@ -85,10 +110,15 @@ def get_librenms_devices_for_import( if filters: # Check for status filter first - it has special handling if filters.get("status") is not None: + # Normalize to int: form fields send strings ("1"/"0"), API may send ints + try: + status_val = int(filters["status"]) + except (ValueError, TypeError): + status_val = None # Status filter uses special types that don't need query param - if filters["status"] == 1: + if status_val == 1: api_filters["type"] = "up" - elif filters["status"] == 0: + elif status_val == 0: api_filters["type"] = "down" # Save ALL other filters for client-side filtering when status is used @@ -170,8 +200,9 @@ def get_librenms_devices_for_import( # We'll filter client-side if needed # Use caching to avoid repeated API calls - # Include both API and client filters in cache key - cache_key = f"librenms_devices_import_{server_key}_{hash(str(api_filters))}_{hash(str(client_filters))}" + # Include both API and client filters in cache key (deterministic, cross-process stable). + # Use api.server_key (always resolved) rather than the raw server_key arg (may differ). + cache_key = get_import_search_cache_key(api.server_key, api_filters, client_filters) from_cache = False if force_refresh: diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py index e68e9b6680..a7f2b0b066 100644 --- a/netbox_librenms_plugin/import_utils/virtual_chassis.py +++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py @@ -1,4 +1,4 @@ -"""Virtual chassis detection, creation, and caching.""" +"""Virtual chassis detection, creation, and management.""" import logging from typing import List @@ -32,11 +32,12 @@ def _clone_virtual_chassis_data(data: dict | None) -> dict: members = [] for idx, member in enumerate(data.get("members", [])): member_copy = member.copy() - raw_position = member_copy.get("position", idx) + raw_position = member_copy.get("position", idx + 1) try: - member_copy["position"] = int(raw_position) + pos = int(raw_position) + member_copy["position"] = pos if pos > 0 else idx + 1 except (TypeError, ValueError): - member_copy["position"] = idx + member_copy["position"] = idx + 1 # 1-based fallback; position 0 is invalid members.append(member_copy) member_count = data.get("member_count") or len(members) @@ -175,7 +176,7 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d logger.debug(f"VC detection: Found parent container at index {parent_index} for device {device_id}") break - if not parent_index: + if parent_index is None: return None # Step 3: Get children chassis at next level @@ -196,13 +197,19 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d return None # Step 5: Extract member info + # Load naming pattern once to avoid a DB query per member. + vc_name_pattern = _load_vc_member_name_pattern() if master_name else None members = [] for idx, chassis in enumerate(chassis_items): - raw_position = chassis.get("entPhysicalParentRelPos", idx) + # entPhysicalParentRelPos is 1-based; fall back to idx+1 (not idx) so + # position 0 is never produced β€” VC positions must be β‰₯ 1. + raw_position = chassis.get("entPhysicalParentRelPos", idx + 1) try: position = int(raw_position) + if position <= 0: + position = idx + 1 except (TypeError, ValueError): - position = idx + position = idx + 1 member_data = { "serial": chassis.get("entPhysicalSerialNum", ""), "position": position, @@ -212,11 +219,12 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d "description": chassis.get("entPhysicalDescr", ""), } - # Generate suggested name if we have master name + # Generate suggested name if we have master name. + # position is already 1-based, so pass it directly (no +1). if master_name: - member_data["suggested_name"] = _generate_vc_member_name(master_name, position + 1) + member_data["suggested_name"] = _generate_vc_member_name(master_name, position, pattern=vc_name_pattern) else: - member_data["suggested_name"] = f"Member-{position + 1}" + member_data["suggested_name"] = f"Member-{position}" members.append(member_data) @@ -232,7 +240,19 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d return None -def _generate_vc_member_name(master_name: str, position: int, serial: str = None) -> str: +def _load_vc_member_name_pattern() -> str: + """Load the VC member name pattern from settings, with fallback to default.""" + from ..models import LibreNMSSettings + + try: + settings = LibreNMSSettings.objects.order_by("pk").first() + return settings.vc_member_name_pattern if settings else "-M{position}" + except Exception as e: + logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.") + return "-M{position}" + + +def _generate_vc_member_name(master_name: str, position: int, serial: str = None, pattern: str = None) -> str: """ Generate name for VC member device using configured pattern from settings. @@ -240,6 +260,9 @@ def _generate_vc_member_name(master_name: str, position: int, serial: str = None master_name: Name of the master/primary device position: VC position number serial: Optional serial number of the member device + pattern: Optional pre-loaded name pattern; if None, loaded from settings. + Pass a pre-loaded pattern when calling inside a loop to avoid + repeated DB queries. Returns: Generated member device name @@ -250,16 +273,8 @@ def _generate_vc_member_name(master_name: str, position: int, serial: str = None pattern="-SW{position}" -> "switch01-SW2" pattern=" [{serial}]" -> "switch01 [ABC123]" """ - # Import here to avoid circular dependency - from ..models import LibreNMSSettings - - # Get pattern from settings with fallback to default - try: - settings = LibreNMSSettings.objects.first() - pattern = settings.vc_member_name_pattern if settings else "-M{position}" - except Exception as e: - logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.") - pattern = "-M{position}" + if pattern is None: + pattern = _load_vc_member_name_pattern() # Prepare format variables format_vars = { @@ -272,7 +287,7 @@ def _generate_vc_member_name(master_name: str, position: int, serial: str = None try: formatted_suffix = pattern.format(**format_vars) return f"{master_name}{formatted_suffix}" - except KeyError as e: + except (KeyError, ValueError, IndexError) as e: logger.error(f"Invalid placeholder in VC naming pattern '{pattern}': {e}. Using default.") return f"{master_name}-M{position}" @@ -294,15 +309,22 @@ def update_vc_member_suggested_names(vc_data: dict, master_name: str) -> dict: if not vc_data or not vc_data.get("is_stack"): return vc_data + # Load naming pattern once to avoid a DB query per member + vc_pattern = _load_vc_member_name_pattern() for idx, member in enumerate(vc_data.get("members", [])): - raw_position = member.get("position", idx) + # Positions are stored as 1-based (from entPhysicalParentRelPos or idx+1 fallback). + # Use them directly for name generation; only replace 0/negative with 1-based fallback. + raw_position = member.get("position", idx + 1) try: - base_position = int(raw_position) + position = int(raw_position) + if position <= 0: + position = idx + 1 except (TypeError, ValueError): - base_position = idx - position = base_position + 1 # Convert to 1-based position - member["position"] = base_position - member["suggested_name"] = _generate_vc_member_name(master_name, position, serial=member.get("serial")) + position = idx + 1 + member["position"] = position + member["suggested_name"] = _generate_vc_member_name( + master_name, position, serial=member.get("serial"), pattern=vc_pattern + ) return vc_data @@ -334,15 +356,17 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis ] """ - # Store original master device state for rollback + # original_master_name is still referenced in warning messages inside the atomic block. original_master_name = master_device.name - original_vc = master_device.virtual_chassis - original_vc_position = master_device.vc_position try: with transaction.atomic(): + # Load naming pattern once to avoid a DB query per member + vc_pattern = _load_vc_member_name_pattern() # Rename master device to include position 1 pattern - master_device_new_name = _generate_vc_member_name(original_master_name, 1, serial=master_device.serial) + master_device_new_name = _generate_vc_member_name( + original_master_name, 1, serial=master_device.serial, pattern=vc_pattern + ) # Check if renamed master conflicts with existing device if Device.objects.filter(name=master_device_new_name).exclude(pk=master_device.pk).exists(): @@ -360,7 +384,7 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis vc = VirtualChassis.objects.create( name=vc_name, master=master_device, - domain=f"librenms-{libre_device['device_id']}", + domain=f"librenms-{libre_device.get('device_id') or master_device.pk}", ) # Update master device @@ -370,11 +394,12 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis # Create member devices for remaining positions position = 2 # Start at 2 (master is 1) + used_positions = {1} # Master occupies position 1 members_created = 0 for member in members_info: - # Skip if this is the master's serial - if member.get("serial") == master_device.serial: + # Skip if this is the master's serial (only when both serials are non-empty) + if member.get("serial") and member.get("serial") == master_device.serial: continue serial = member.get("serial") @@ -389,7 +414,30 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis logger.warning(f"Device with serial '{serial}' already exists, skipping VC member creation") continue - member_name = _generate_vc_member_name(master_base_name, position, serial=serial) + # Prefer the discovered SNMP position; fall back to sequential counter. + # Normalize discovered_pos: 0 is not a valid VC position, treat as absent. + try: + discovered_pos = int(member.get("position")) if member.get("position") is not None else None + except (TypeError, ValueError): + discovered_pos = None + if discovered_pos is not None and discovered_pos < 1: + discovered_pos = None # 0 is invalid for vc_position; fall back to counter + # If discovered_pos is already taken by another member, treat as absent. + if discovered_pos is not None and discovered_pos in used_positions: + discovered_pos = None + # Consume next free sequential slot when no valid discovered_pos. + if discovered_pos is None: + while position in used_positions: + position += 1 + chosen_pos = position + position += 1 + else: + chosen_pos = discovered_pos + # Advance sequential counter past chosen position. + position = max(position, chosen_pos + 1) + used_positions.add(chosen_pos) + + member_name = _generate_vc_member_name(master_base_name, chosen_pos, serial=serial, pattern=vc_pattern) # Check for duplicate name if Device.objects.filter(name=member_name).exists(): @@ -406,15 +454,16 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis platform=master_device.platform, serial=serial, virtual_chassis=vc, - vc_position=position, + vc_position=chosen_pos, comments=f"VC member (LibreNMS: {member.get('name', 'Unknown')})\n" f"Auto-created from stack inventory", ) members_created += 1 - position += 1 # Validate member count - expected_members = len([m for m in members_info if m.get("serial") != master_device.serial]) + expected_members = len( + [m for m in members_info if not (m.get("serial") and m.get("serial") == master_device.serial)] + ) if members_created < expected_members: logger.warning( f"Created {members_created} members but expected {expected_members}. " @@ -429,12 +478,10 @@ def create_virtual_chassis_with_members(master_device: Device, members_info: lis return vc except Exception as e: - # Rollback master device to original state + # The transaction.atomic() block above will roll back all DB changes automatically. + # Manual state restoration is redundant and the save() would fail in a broken transaction. logger.error( - f"Virtual Chassis creation failed for device {master_device.name}: {e}. Rolling back master device changes." + f"Virtual Chassis creation failed for device {master_device.name}: {e}", + exc_info=True, ) - master_device.name = original_master_name - master_device.virtual_chassis = original_vc - master_device.vc_position = original_vc_position - master_device.save() raise diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index e7deb5d2fd..2ca9af2a97 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -1,8 +1,9 @@ -"""Virtual machine import operations.""" +"""Virtual machine creation and import operations.""" import logging from dcim.models import DeviceRole +from django.db import transaction from django.utils import timezone from virtualization.models import Cluster @@ -13,7 +14,9 @@ logger = logging.getLogger(__name__) -def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: bool = True, role=None): +def create_vm_from_librenms( + libre_device: dict, validation: dict, use_sysname: bool = True, role=None, server_key: str = "default" +): """ Create a NetBox VirtualMachine from LibreNMS device data. @@ -22,6 +25,7 @@ def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: b validation: Validation result from validate_device_for_import with import_as_vm=True use_sysname: If True, prefer sysName; if False, use hostname role: Optional DeviceRole to assign to the VM + server_key: LibreNMS server key used to store the librenms_id custom field Returns: Created VirtualMachine instance @@ -51,15 +55,24 @@ def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: b # Generate import timestamp comment import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z") - # Create the VM with librenms_id custom field - vm = VirtualMachine.objects.create( - name=vm_name, - cluster=cluster, - role=role, # Optional VM role - platform=platform, - comments=f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}", - custom_field_data={"librenms_id": int(libre_device["device_id"])}, - ) + # Validate device_id before creating the VM so a missing/invalid value + # never leaves a VM without a librenms_id (partial persistence). + librenms_device_id = int(libre_device["device_id"]) + + from ..utils import set_librenms_device_id + + # Create the VM and assign its LibreNMS ID atomically so a failure in + # set_librenms_device_id never leaves a VM without a mapping. + with transaction.atomic(): + vm = VirtualMachine.objects.create( + name=vm_name, + cluster=cluster, + role=role, # Optional VM role + platform=platform, + comments=f"Imported from LibreNMS (device_id={librenms_device_id}) by netbox-librenms-plugin on {import_time}", + ) + set_librenms_device_id(vm, librenms_device_id, server_key) + vm.save() logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}") return vm @@ -162,6 +175,7 @@ def bulk_import_vms( api=api, use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, + server_key=api.server_key, ) # Check if VM already exists @@ -206,7 +220,9 @@ def bulk_import_vms( libre_device["_computed_name"] = vm_name # Create VM - vm = create_vm_from_librenms(libre_device, validation, use_sysname=use_sysname, role=role) + vm = create_vm_from_librenms( + libre_device, validation, use_sysname=use_sysname, role=role, server_key=api.server_key + ) result["success"].append( { diff --git a/netbox_librenms_plugin/jobs.py b/netbox_librenms_plugin/jobs.py index 80c0903ed2..f56d564e46 100644 --- a/netbox_librenms_plugin/jobs.py +++ b/netbox_librenms_plugin/jobs.py @@ -113,6 +113,8 @@ def run( "filters": filters, "server_key": server_key, "vc_detection_enabled": vc_detection_enabled, + "use_sysname": use_sysname, + "strip_domain": strip_domain, "cache_timeout": api.cache_timeout, "cached_at": cached_at, "completed": True, diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 5de9db6c25..3291aad0cc 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -190,7 +190,9 @@ def get_librenms_id(self, obj): If found via API, stores ID in custom field if available, otherwise caches the value. """ - librenms_id = obj.cf.get("librenms_id") + from netbox_librenms_plugin.utils import get_librenms_device_id + + librenms_id = get_librenms_device_id(obj, self.server_key) if librenms_id: return librenms_id @@ -254,7 +256,9 @@ def _store_librenms_id(self, obj, librenms_id): None """ if "librenms_id" in obj.cf: - obj.custom_field_data["librenms_id"] = librenms_id + from netbox_librenms_plugin.utils import set_librenms_device_id + + set_librenms_device_id(obj, librenms_id, self.server_key) obj.save() else: # Use cache as fallback @@ -686,6 +690,51 @@ def get_device_inventory(self, device_id): except requests.exceptions.RequestException 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() + + if response.status_code == 200: + data = response.json() + return True, data.get("transceivers", []) + return False, [] + except requests.exceptions.RequestException as e: + return False, str(e) + def get_poller_groups(self): """ Fetch all poller groups from LibreNMS. diff --git a/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py b/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py new file mode 100644 index 0000000000..dcd8fc4fd5 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.10 on 2026-02-17 11:48 + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0008_librenmssettings_import_defaults"), + ] + + operations = [ + 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)), + ( + "netbox_device_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.devicetype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_hardware"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py b/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py new file mode 100644 index 0000000000..796bbceafd --- /dev/null +++ b/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.10 on 2026-02-17 12:23 + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0009_add_devicetypemapping"), + ] + + operations = [ + migrations.AlterModelOptions( + name="interfacetypemapping", + options={"ordering": ["librenms_type", "librenms_speed"]}, + ), + 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, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_module_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.moduletype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_model"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0011_modulebaymapping.py b/netbox_librenms_plugin/migrations/0011_modulebaymapping.py new file mode 100644 index 0000000000..5b3c2c3be0 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0011_modulebaymapping.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.10 on 2026-02-17 12:29 + +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0010_add_moduletypemapping"), + ] + + operations = [ + 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)), + ("description", models.TextField(blank=True)), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_name"], + "unique_together": {("librenms_name", "librenms_class")}, + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py b/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py new file mode 100644 index 0000000000..52ff053e20 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_librenms_plugin", "0011_modulebaymapping"), + ] + + operations = [ + migrations.AddField( + model_name="modulebaymapping", + name="is_regex", + field=models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0013_normalizationrule.py b/netbox_librenms_plugin/migrations/0013_normalizationrule.py new file mode 100644 index 0000000000..71d1f80509 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0013_normalizationrule.py @@ -0,0 +1,93 @@ +"""Restore NormalizationRule model. + +The table was created by earlier migrations (0013 + 0014 in a previous branch) +and already exists in the database. This migration uses SeparateDatabaseAndState +so Django's ORM knows about the model without trying to CREATE the table again. +If the table doesn't exist (fresh install), the database_operations handle creation. +""" + +import django.db.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0001_initial"), + ("extras", "0001_initial"), + ("netbox_librenms_plugin", "0012_add_is_regex_to_modulebaymapping"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + 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( + choices=[ + ("module_type", "Module Type"), + ("device_type", "Device Type"), + ("module_bay", "Module Bay"), + ], + 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)), + ( + "manufacturer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="normalization_rules", + to="dcim.manufacturer", + ), + ), + ( + "tags", + taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + ], + options={ + "ordering": ["scope", "priority", "pk"], + }, + ), + ], + database_operations=[ + migrations.RunSQL( + sql=""" + CREATE TABLE IF NOT EXISTS "netbox_librenms_plugin_normalizationrule" ( + "id" bigserial NOT NULL PRIMARY KEY, + "created" timestamp with time zone NULL, + "last_updated" timestamp with time zone NULL, + "custom_field_data" jsonb NOT NULL DEFAULT '{}'::jsonb, + "scope" varchar(50) NOT NULL, + "match_pattern" varchar(500) NOT NULL, + "replacement" varchar(500) NOT NULL, + "priority" integer NOT NULL DEFAULT 100 CHECK ("priority" >= 0), + "description" text NOT NULL DEFAULT '', + "manufacturer_id" bigint NULL REFERENCES "dcim_manufacturer" ("id") + DEFERRABLE INITIALLY DEFERRED + ); + CREATE INDEX IF NOT EXISTS "netbox_librenms_plugin_norm_mfg_idx" + ON "netbox_librenms_plugin_normalizationrule" ("manufacturer_id"); + """, + reverse_sql="DROP TABLE IF EXISTS netbox_librenms_plugin_normalizationrule;", + ), + ], + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f47550..cb76ad2671 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -1,4 +1,8 @@ +import re + from dcim.choices import InterfaceTypeChoices +from dcim.models import DeviceType, Manufacturer, ModuleType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from netbox.models import NetBoxModel @@ -71,6 +75,214 @@ class Meta: """Meta options for InterfaceTypeMapping.""" unique_together = ["librenms_type", "librenms_speed"] + ordering = ["librenms_type", "librenms_speed"] def __str__(self): return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}" + + +class DeviceTypeMapping(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_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 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}" + + +class ModuleTypeMapping(NetBoxModel): + """Map LibreNMS inventory model names to NetBox ModuleType objects.""" + + librenms_model = models.CharField( + max_length=255, + unique=True, + help_text="Model name from LibreNMS inventory (entPhysicalModelName)", + ) + netbox_module_type = models.ForeignKey( + ModuleType, + on_delete=models.CASCADE, + related_name="librenms_mappings", + help_text="The NetBox ModuleType this model name maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + 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"] + + def __str__(self): + return f"{self.librenms_model} -> {self.netbox_module_type}" + + +class ModuleBayMapping(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", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Validate that regex patterns compile when is_regex is True.""" + super().clean() + 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: + pattern.sub(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.""" + + unique_together = ["librenms_name", "librenms_class"] + ordering = ["librenms_name"] + + def __str__(self): + cls = f" [{self.librenms_class}]" if self.librenms_class else "" + return f"{self.librenms_name}{cls} -> {self.netbox_bay_name}" + + +class NormalizationRule(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, + help_text="Which matching lookup this rule applies to", + ) + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + 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() + 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: + compiled.sub(self.replacement, "") + except re.error 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}" diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index a08e62740f..052c06363c 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -31,6 +31,74 @@ ), ), ), + PluginMenuItem( + 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", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + 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", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + 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", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + 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", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), ), ), ( 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 08a0534714..68fb8d2641 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 @@ -1095,8 +1095,8 @@ // Initialize Bootstrap tooltips inside the freshly-swapped modal content if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) { - const tooltips = modalContent.querySelectorAll('[data-bs-toggle="tooltip"]'); - [...tooltips].forEach(el => new bootstrap.Tooltip(el)); + const tooltipEls = modalContent.querySelectorAll('[data-bs-toggle="tooltip"]'); + for (const el of tooltipEls) { bootstrap.Tooltip.getOrCreateInstance(el); } } showModal(modalElement, fallbackBackdropRef); diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js index cd470af1b1..0522cf66d2 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 @@ -153,11 +153,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"); } // ============================================ @@ -1400,6 +1404,39 @@ function initializeSyncFormSpinners() { * Initialize all sync page functionality. * Called on DOMContentLoaded and after HTMX content swaps. */ +/** + * Wire the "Install Selected" form to collect checked module-table rows before submit. + * The form is separate from the table (to avoid nested forms), so we copy the + * selected checkbox values into hidden inputs just before the form is submitted. + * Guard against duplicate listeners on repeated HTMX swaps via a data attribute. + */ +function handleInstallSelectedSubmit() { + // Remove any previously-injected hidden inputs to avoid duplicates + const form = document.getElementById('install-selected-form'); + if (!form) return; + form.querySelectorAll('input[data-injected-select]').forEach(el => { el.remove(); }); + + const table = document.getElementById('librenms-module-table'); + if (!table) return; + + table.querySelectorAll('input[name="select"]:checked').forEach(cb => { + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'select'; + hidden.value = cb.value; + hidden.dataset.injectedSelect = '1'; + form.appendChild(hidden); + }); +} + +function initializeInstallSelectedForm() { + const form = document.getElementById('install-selected-form'); + if (!form) return; + if (form.dataset.installInit) return; + form.dataset.installInit = 'true'; + form.addEventListener('submit', handleInstallSelectedSubmit); +} + function initializeScripts() { initializeCheckboxes(); initializeVCMemberSelect(); @@ -1416,6 +1453,7 @@ function initializeScripts() { initializeNetBoxOnlyInterfaces(); initializeSyncFormSpinners(); initializeVlanSyncGroupSelects(); + initializeInstallSelectedForm(); } diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py index e8b4fd8cf6..3f03764cef 100644 --- a/netbox_librenms_plugin/tables/device_status.py +++ b/netbox_librenms_plugin/tables/device_status.py @@ -479,15 +479,21 @@ def render_actions(self, value, record): btn_class = "btn-outline-warning" btn_icon = "mdi-information-outline" btn_label = " Details" + elif match_type == "librenms_id" and validation.get("librenms_id_needs_migration"): + btn_class = "btn-outline-warning" + btn_icon = "mdi-database-alert" + btn_label = " Legacy ID" else: btn_class = "btn-outline-success" btn_icon = "mdi-check-circle" btn_label = "" btn_title = "Resolve conflict" if (has_actions or has_mismatch) else "View details" + aria_attr = f'aria-label="{btn_title}" ' if btn_label == "" else "" buttons.append( f'", + url, + self.csrf_token, + self.server_key, + record.get("module_bay_id", ""), + record.get("module_type_id", ""), + record.get("serial", ""), + ) + ) + + # Install branch button for parents with installable children + if record.get("has_installable_children") and record.get("ent_physical_index"): + url = reverse("plugins:netbox_librenms_plugin:install_branch", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record.get("ent_physical_index", ""), + ) + ) + + return format_html("{}", mark_safe("".join(str(b) for b in buttons))) if buttons else "" diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html new file mode 100644 index 0000000000..4c2c5ae65d --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html @@ -0,0 +1,27 @@ +{% load helpers %} + + +
+

Module Sync

+
+
+ {% csrf_token %} + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + +
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html new file mode 100644 index 0000000000..afd1dd5c28 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html @@ -0,0 +1,42 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if module_sync.table %} +
+
+ + Showing inventory items from LibreNMS matched against NetBox module bays and module types. + +
+ {% if module_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +{# Separate form for Install Selected β€” uses JS to collect checked rows before submit #} +
+ {% csrf_token %} + +
+ +
+
+
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} + {% include 'inc/table.html' with table=module_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} +
+{% else %} +
+
+ +

No inventory data loaded. Click Refresh Modules to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html new file mode 100644 index 0000000000..3894441179 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS HardwareNetBox Device TypeDescription
{{ object.librenms_hardware }}{{ object.netbox_device_type }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html new file mode 100644 index 0000000000..06c95270b3 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Device Type Mapping

+

Map LibreNMS hardware strings to NetBox device types. + When importing devices from LibreNMS, these mappings are checked first before + falling back to exact part number / model matching.

+

Example: Map "Juniper MX480 Internet Backbone Router" to device type "MX480"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index 8e4bc50d75..47f4b101fb 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -47,9 +47,9 @@
LibreNMS Status
Status - {% if libre_device.status == 1 %} + {% if libre_device.status == 1 or libre_device.status == "1" %} Up - {% elif libre_device.status == 0 %} + {% elif libre_device.status == 0 or libre_device.status == "0" %} Down {% else %} Unknown @@ -109,7 +109,7 @@
Name {% if validation.existing_device %} - + {{ validation.existing_device.name }} {% if validation.name_sync_available %} @@ -120,7 +120,7 @@
{% csrf_token %} - @@ -129,9 +129,7 @@
New device {% endif %} - - {{ libre_device.sysName|default:libre_device.hostname }} - + {{ libre_device.sysName|default:libre_device.hostname }} {% if not validation.import_as_vm %} @@ -171,7 +169,7 @@
- @@ -184,7 +182,7 @@
{% csrf_token %} - @@ -192,8 +190,10 @@
{% endif %} {% elif validation.device_type.device_type %} - {{ validation.device_type.device_type }} - + + {{ validation.device_type.device_type }} + + {% else %} No matching type {% endif %} @@ -225,7 +225,7 @@
{% csrf_token %} - @@ -280,7 +280,7 @@
{% csrf_token %} - @@ -305,7 +305,7 @@
{% csrf_token %} - @@ -376,7 +376,13 @@
{% if validation.existing_device %} {% if validation.existing_match_type == 'librenms_id' %}
- Linked β€” ID {{ libre_device.device_id }} + {% if existing_id_servers %} + {% for srv in existing_id_servers %} + Linked β€” ID {{ srv.device_id }} @ {{ srv.display_name }} + {% endfor %} + {% else %} + Linked β€” ID {{ libre_device.device_id }} + {% endif %} {% if validation.name_matches %} Name match {% elif validation.name_sync_available %} @@ -392,7 +398,33 @@
{% if validation.device_type_mismatch %} Type mismatch {% endif %} + {% if validation.librenms_id_needs_migration %} + Legacy ID format + {% endif %}
+ {% if validation.librenms_id_needs_migration %} +
+
+ {% csrf_token %} + + + {% if not validation.serial_confirmed %} +
+ + +
+ {% endif %} + +
+
+ {% endif %} {% elif validation.existing_match_type == 'hostname' %}
@@ -409,7 +441,7 @@
{% endif %} β€” Exists as - {{ validation.existing_device.name }}, + {{ validation.existing_device.name }}, not linked to LibreNMS.
@@ -469,7 +501,7 @@
{% endif %} β€” Exists as - {{ validation.existing_device.name }}, + {{ validation.existing_device.name }}, not linked to LibreNMS. @@ -507,7 +539,7 @@
IP match β€” Device with IP {{ libre_device.ip }} exists as - {{ validation.existing_device.name }}. + {{ validation.existing_device.name }}. Consider adding LibreNMS ID manually. @@ -516,7 +548,7 @@
{% endif %} @@ -559,17 +591,17 @@
{% if validation.existing_device %} {% if validation.import_as_vm or validation.existing_device.cluster %} + class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer"> View VM in NetBox {% else %} + class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer"> View in NetBox {% if validation.existing_match_type == 'librenms_id' %} + class="btn btn-outline-primary btn-sm" target="_blank" rel="noopener noreferrer"> Full Sync Page {% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html new file mode 100644 index 0000000000..4c2c5ae65d --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/_module_sync.html @@ -0,0 +1,27 @@ +{% load helpers %} + + +
+

Module Sync

+
+
+ {% csrf_token %} + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + +
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html index 82fd96ff03..91c49290fa 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -29,7 +29,73 @@ {% block content %} -{% if librenms_server_info %} +{% if all_server_mappings %} +
+
+ LibreNMS Connections + {% if librenms_server_info and not librenms_server_info.is_legacy %} + + Change Server + + {% endif %} +
+
+ + + {% for mapping in all_server_mappings %} + + + + + + {% endfor %} + +
+ {% if mapping.is_active %} + + {% elif mapping.is_configured %} + + {% else %} + + {% endif %} + {% if mapping.is_configured %} + {{ mapping.display_name }} + {% else %} + {{ mapping.server_key }} + Not configured + {% endif %} + + {% if mapping.device_url %} + + ID {{ mapping.device_id }} + + + {% else %} + ID {{ mapping.device_id }} + {% endif %} + + {% if not mapping.is_configured %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% endwith %} + {% endif %} +
+
+
+{% elif librenms_server_info %}
@@ -249,7 +315,7 @@
Device Information Sync
{{ object.name }}
- {% if sysName and sysName != object.name %} + {% if sysName and sysName != "-" and sysName != object.name %}
@@ -259,7 +325,7 @@
Device Information Sync
Sync to NetBox
- {% elif sysName %} + {% elif sysName and sysName != "-" %} @@ -535,6 +601,14 @@
Device Information Sync
{% endif %} {% endwith %} + {% if module_sync %} + + {% endif %}
Device Information Sync
{% include 'netbox_librenms_plugin/_ipaddress_sync.html' %} + {% if module_sync %} +
+ {% include 'netbox_librenms_plugin/_module_sync.html' %} +
+ {% endif %} + {% with model_name=object|meta:"model_name" %} {% if model_name == "device" %}
+
+
+ + + + + + + + + + + + + + + + + + + +
LibreNMS NameLibreNMS ClassNetBox Bay NameRegex?Description
{{ object.librenms_name }}{{ object.librenms_class|default:"β€”" }}{{ object.netbox_bay_name }}{{ object.is_regex|yesno:"Yes,No" }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html new file mode 100644 index 0000000000..fb87f901ec --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Module Bay Mapping

+

Map LibreNMS inventory container names to NetBox module bay names. + When synchronizing modules from LibreNMS, these mappings determine which + NetBox module bay a LibreNMS component should be installed into.

+

Example: Map "Linecard(slot 1)" to "Slot 1", or "Power Supply 1" to "PS1"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html new file mode 100644 index 0000000000..019b0e51ed --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS ModelNetBox Module TypeDescription
{{ object.librenms_model }}{{ object.netbox_module_type }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html new file mode 100644 index 0000000000..4cfc22d592 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Module Type Mapping

+

Map LibreNMS inventory model names (entPhysicalModelName) to NetBox module types. + When synchronizing modules from LibreNMS, these mappings are checked first before + falling back to exact model / part number matching.

+

Example: Map "710-017414" to module type "WS-X4908-10GE"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html new file mode 100644 index 0000000000..a1be7537a3 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html @@ -0,0 +1,34 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
ScopeManufacturerMatch PatternReplacementPriorityDescription
{{ object.get_scope_display }}{% if object.manufacturer %}{{ object.manufacturer }}{% else %}β€”{% endif %}{{ object.match_pattern }}{{ object.replacement }}{{ object.priority }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html new file mode 100644 index 0000000000..d543141680 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html @@ -0,0 +1,16 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Normalization Rules

+

Regex-based string normalization applied before matching lookups. + When a LibreNMS string doesn't match any NetBox object or mapping entry, + normalization rules transform it (e.g. strip revision suffixes) and retry.

+

Rules are chained in priority order per scope. One rule engine serves + module types, device types, and module bays.

+

Example β€” strip Nokia revision suffixes:
+ ^(3HE\w{5}[A-Z]{2})[A-Z]{2}\d{2}$ β†’ \1
+ Turns 3HE16474AARA01 into 3HE16474AA which matches the part number.

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/tests/mock_librenms_server.py b/netbox_librenms_plugin/tests/mock_librenms_server.py new file mode 100644 index 0000000000..652c73370b --- /dev/null +++ b/netbox_librenms_plugin/tests/mock_librenms_server.py @@ -0,0 +1,148 @@ +"""Minimal HTTP mock for LibreNMS API responses. + +Usage in tests (add to conftest.py or inline): + + from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server + + @pytest.fixture + def librenms_server(): + with librenms_mock_server() as server: + yield server +""" + +import json +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse + + +class _LibreNMSHandler(BaseHTTPRequestHandler): + """Request handler that dispatches to registered route responses.""" + + def log_message(self, format, *args): # noqa: A002 + pass # Suppress request logs in tests + + def _send_json(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + path = urlparse(self.path).path + routes = self.server.routes # type: ignore[attr-defined] + if path in routes: + status, body = routes[path] + self._send_json(status, body) + else: + self._send_json(404, {"status": "error", "message": f"No mock for {path}"}) + + def do_POST(self): + self.do_GET() + + +class MockLibreNMSServer: + """Context-manager wrapper around a simple HTTP mock server. + + Attributes: + url (str): Base URL for the mock server (e.g. "http://127.0.0.1:PORT"). + routes (dict): Mapping of URL path β†’ (status_code, body_dict). + """ + + def __init__(self): + self._server = HTTPServer(("127.0.0.1", 0), _LibreNMSHandler) + self._server.routes = {} + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + _, port = self._server.server_address + self.url = f"http://127.0.0.1:{port}" + + def register(self, path: str, body: dict, status: int = 200): + """Register a mock response for a URL path.""" + self._server.routes[path] = (status, body) + + def start(self): + self._thread.start() + return self + + def stop(self): + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + if self._thread.is_alive(): + import warnings + + warnings.warn( + f"MockLibreNMSServer thread {self._thread.ident} did not exit within 5 s; " + "socket may not be fully released", + ResourceWarning, + stacklevel=2, + ) + + # ------- default LibreNMS-shaped responses ------- + + def add_device_response(self, device_id: int = 1, hostname: str = "test-host"): + self.register( + "/api/v0/devices", + {"status": "ok", "id": device_id, "hostname": hostname}, + ) + + def device_info_response( + self, + device_id: int = 1, + hostname: str = "test-host", + hardware: str = "WS-C3560X-24T-S", + os: str = "ios", + serial: str = "SN123", + ): + self.register( + f"/api/v0/devices/{device_id}", + { + "status": "ok", + "devices": [ + { + "device_id": device_id, + "hostname": hostname, + "hardware": hardware, + "os": os, + "serial": serial, + "sysName": hostname, + } + ], + }, + ) + + def ports_response(self, device_id: int = 1, ports=None): + if ports is None: + ports = [ + { + "port_id": 101, + "ifName": "GigabitEthernet0/1", + "ifDescr": "GigabitEthernet0/1", + "ifType": "ethernetCsmacd", + "ifSpeed": 1_000_000_000, + "ifAdminStatus": "up", + "ifAlias": "uplink", + "ifPhysAddress": "aa:bb:cc:dd:ee:01", + "ifMtu": 1500, + "ifVlan": 1, + "ifTrunk": 0, + } + ] + self.register(f"/api/v0/devices/{device_id}/ports", {"status": "ok", "ports": ports}) + + def auth_error_response(self, path="/api/v0/devices"): + self.register(path, {"status": "error", "message": "Authentication failed"}, status=401) + + +@contextmanager +def librenms_mock_server(): + """Context manager that starts and stops a MockLibreNMSServer.""" + server = MockLibreNMSServer() + server.start() + try: + yield server + finally: + server.stop() diff --git a/netbox_librenms_plugin/tests/test_background_jobs.py b/netbox_librenms_plugin/tests/test_background_jobs.py index 13dca842d8..be7f7af377 100644 --- a/netbox_librenms_plugin/tests/test_background_jobs.py +++ b/netbox_librenms_plugin/tests/test_background_jobs.py @@ -686,6 +686,8 @@ def test_load_success_uses_correct_cache_keys(self, mock_job_class, mock_get_key "vc_detection_enabled": True, "cached_at": "2026-01-20T10:00:00Z", "cache_timeout": 600, + "use_sysname": True, + "strip_domain": False, } mock_job_class.objects.get.return_value = mock_job @@ -708,12 +710,16 @@ def test_load_success_uses_correct_cache_keys(self, mock_job_class, mock_get_key filters={"location": "dc1"}, device_id=1, vc_enabled=True, + use_sysname=True, + strip_domain=False, ) mock_get_key.assert_any_call( server_key="primary", filters={"location": "dc1"}, device_id=2, vc_enabled=True, + use_sysname=True, + strip_domain=False, ) assert len(results) == 2 @@ -734,6 +740,8 @@ def test_load_extracts_filters_from_job_data(self, mock_job_class, mock_get_key, "vc_detection_enabled": False, "cached_at": "2026-01-20T10:00:00Z", "cache_timeout": 300, + "use_sysname": True, + "strip_domain": False, } mock_job_class.objects.get.return_value = mock_job mock_get_key.return_value = "test_key" @@ -748,6 +756,8 @@ def test_load_extracts_filters_from_job_data(self, mock_job_class, mock_get_key, filters={"location": "dc2", "type": "router"}, device_id=1, vc_enabled=False, + use_sysname=True, + strip_domain=False, ) @patch("netbox_librenms_plugin.views.imports.list.cache") diff --git a/netbox_librenms_plugin/tests/test_import_utils.py b/netbox_librenms_plugin/tests/test_import_utils.py index 52c7ffd9af..097c5ffd7a 100644 --- a/netbox_librenms_plugin/tests/test_import_utils.py +++ b/netbox_librenms_plugin/tests/test_import_utils.py @@ -64,6 +64,31 @@ def test_get_import_device_cache_key(self): assert "secondary" in key assert "456" in key + def test_validated_device_cache_key_unique_per_naming_mode(self): + """Different naming preferences produce different cache keys.""" + from netbox_librenms_plugin.import_utils import get_validated_device_cache_key + + base_args = dict(server_key="default", filters={}, device_id=123, vc_enabled=False) + key_default = get_validated_device_cache_key(**base_args) + key_no_sysname = get_validated_device_cache_key(**base_args, use_sysname=False) + key_strip = get_validated_device_cache_key(**base_args, strip_domain=True) + + assert key_default != key_no_sysname + assert key_default != key_strip + assert key_no_sysname != key_strip + + def test_cache_metadata_key_unique_per_naming_mode(self): + """Different naming preferences produce different metadata cache keys.""" + from netbox_librenms_plugin.import_utils import get_cache_metadata_key + + base_args = dict(server_key="default", filters={}, vc_enabled=False) + key_default = get_cache_metadata_key(**base_args) + key_no_sysname = get_cache_metadata_key(**base_args, use_sysname=False) + key_strip = get_cache_metadata_key(**base_args, strip_domain=True) + + assert key_default != key_no_sysname + assert key_default != key_strip + # ============================================================================= # TestDeviceNameDetermination - 6 tests @@ -150,7 +175,7 @@ class TestDeviceRetrieval: """Test device retrieval and filtering functions.""" @patch("netbox_librenms_plugin.import_utils.filters.cache") - @patch("netbox_librenms_plugin.import_utils.device_operations.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.filters.LibreNMSAPI") def test_get_librenms_devices_for_import_success(self, mock_api_class, mock_cache): """Retrieve devices from LibreNMS API.""" mock_cache.get.return_value = None # Cache miss @@ -227,11 +252,11 @@ def test_get_device_count_for_filters_success(self, mock_cache): @patch("netbox_librenms_plugin.import_utils.filters.cache") def test_get_device_count_excludes_disabled(self, mock_cache): - """Count respects show_disabled filter parameter.""" + """Count respects show_disabled filter parameter: disabled==1 devices excluded.""" mock_cache.get.return_value = [ - {"device_id": 1, "hostname": "switch-01", "status": 1}, - {"device_id": 2, "hostname": "switch-02", "status": 1}, - {"device_id": 3, "hostname": "switch-03", "status": 0}, # disabled + {"device_id": 1, "hostname": "switch-01", "disabled": 0, "status": 1}, + {"device_id": 2, "hostname": "switch-02", "disabled": 0, "status": 0}, + {"device_id": 3, "hostname": "switch-03", "disabled": 1, "status": 1}, # disabled in LibreNMS ] mock_api = MagicMock() @@ -341,7 +366,6 @@ def test_validate_device_site_match_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [mock_site] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -399,7 +423,6 @@ def test_validate_device_site_not_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -461,7 +484,6 @@ def test_validate_device_platform_match_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -519,7 +541,6 @@ def test_validate_device_platform_not_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -577,7 +598,6 @@ def test_validate_device_type_match_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -638,7 +658,6 @@ def test_validate_device_type_not_found( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -651,7 +670,7 @@ def test_validate_device_type_not_found( result = validate_device_for_import(device_data, include_vc_detection=False) - assert result["device_type"]["matched"] is False + assert result["device_type"]["found"] is False assert any("device type" in issue.lower() for issue in result["issues"]) @patch("virtualization.models.VirtualMachine") @@ -698,7 +717,6 @@ def test_validate_device_role_required( } mock_role.objects.all.return_value = [MagicMock(id=1, name="Access Switch")] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [mock_site] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -757,7 +775,6 @@ def test_validate_device_handles_empty_location( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -816,7 +833,6 @@ def test_validate_device_handles_empty_os( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -877,7 +893,6 @@ def test_validate_device_handles_empty_hardware( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -891,7 +906,7 @@ def test_validate_device_handles_empty_hardware( result = validate_device_for_import(device_data, include_vc_detection=False) assert result is not None - assert result["device_type"]["matched"] is False + assert result["device_type"]["found"] is False @patch("virtualization.models.VirtualMachine") @patch("netbox_librenms_plugin.import_utils.device_operations.Device") @@ -974,7 +989,6 @@ def test_validate_device_returns_complete_state( } mock_role.objects.all.return_value = [] mock_cluster.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1000,7 +1014,6 @@ def test_validate_device_returns_complete_state( assert "platform" in result @patch("netbox_librenms_plugin.import_utils.device_operations.cache") - @patch("virtualization.models.Cluster") @patch("virtualization.models.VirtualMachine") @patch("netbox_librenms_plugin.import_utils.device_operations.Device") @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") @@ -1014,14 +1027,13 @@ def test_validate_device_import_as_vm( self, mock_site_model, mock_rack, - mock_cluster_module, + mock_cluster, mock_role, mock_match_type, mock_find_platform, mock_find_site, mock_device, mock_vm, - mock_cluster_local, mock_cache, ): """Import as VM mode uses cluster instead of site/device_type.""" @@ -1045,10 +1057,8 @@ def test_validate_device_import_as_vm( } mock_role.objects.all.return_value = [] mock_clusters = [MagicMock(id=1, name="VMware Cluster")] - # Cluster is imported at module level in device_operations - mock_cluster_module.objects.all.return_value = mock_clusters + mock_cluster.objects.all.return_value = mock_clusters mock_cache.get.return_value = None # Force cache miss to trigger Cluster.objects.all() - mock_rack.objects.filter.return_value = [] mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1104,201 +1114,15 @@ def test_validate_device_existing_vm_blocks_import( assert result["import_as_vm"] is True -class TestDeviceNamingPreferences: - """Test that validation honours use_sysname and strip_domain user preferences.""" - - COMMON_PATCHES = [ - "netbox_librenms_plugin.import_utils.device_operations.Site", - "netbox_librenms_plugin.import_utils.device_operations.Rack", - "netbox_librenms_plugin.import_utils.device_operations.Cluster", - "netbox_librenms_plugin.import_utils.device_operations.DeviceRole", - "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", - "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", - "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", - "netbox_librenms_plugin.import_utils.device_operations.Device", - "virtualization.models.VirtualMachine", - ] - - def _setup_no_existing(self, mocks): - """Configure mocks so no existing device is found.""" - mock_vm = mocks[-1] # VirtualMachine - mock_device = mocks[-2] # Device - mock_find_site = mocks[-3] - mock_find_platform = mocks[-4] - mock_match_type = mocks[-5] - mock_role = mocks[-6] - mock_rack = mocks[-8] - mock_site_model = mocks[-9] - - mock_vm.objects.filter.return_value.first.return_value = None - mock_device.objects.filter.return_value.first.return_value = None - mock_find_site.return_value = { - "found": False, - "site": None, - "match_type": None, - "confidence": 0.0, - } - mock_find_platform.return_value = { - "found": False, - "platform": None, - "match_type": None, - } - mock_match_type.return_value = { - "matched": False, - "device_type": None, - "match_type": None, - } - mock_role.objects.all.return_value = [] - mock_rack.objects.filter.return_value = [] - mock_site_model.objects.all.return_value = [] - - @patch("virtualization.models.VirtualMachine") - @patch("netbox_librenms_plugin.import_utils.device_operations.Device") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") - @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") - @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") - @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") - @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") - @patch("netbox_librenms_plugin.import_utils.device_operations.Site") - def test_resolved_name_uses_sysname_by_default(self, *mocks): - """Default use_sysname=True uses sysName for resolved_name.""" - self._setup_no_existing(mocks) - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "10.0.0.1", - "sysName": "core-switch", - } - result = validate_device_for_import(device_data, include_vc_detection=False) - assert result["resolved_name"] == "core-switch" - - @patch("virtualization.models.VirtualMachine") - @patch("netbox_librenms_plugin.import_utils.device_operations.Device") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") - @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") - @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") - @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") - @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") - @patch("netbox_librenms_plugin.import_utils.device_operations.Site") - def test_resolved_name_uses_hostname_when_sysname_disabled(self, *mocks): - """use_sysname=False uses hostname for resolved_name.""" - self._setup_no_existing(mocks) - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "10.0.0.1", - "sysName": "core-switch", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - use_sysname=False, - ) - assert result["resolved_name"] == "10.0.0.1" - - @patch("virtualization.models.VirtualMachine") - @patch("netbox_librenms_plugin.import_utils.device_operations.Device") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") - @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") - @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") - @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") - @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") - @patch("netbox_librenms_plugin.import_utils.device_operations.Site") - def test_resolved_name_strips_domain(self, *mocks): - """strip_domain=True strips the domain suffix.""" - self._setup_no_existing(mocks) - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "switch-01.example.com", - "sysName": "switch-01.example.com", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - strip_domain=True, - ) - assert result["resolved_name"] == "switch-01" - - @patch("virtualization.models.VirtualMachine") - @patch("netbox_librenms_plugin.import_utils.device_operations.Device") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") - @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") - @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") - @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") - @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") - @patch("netbox_librenms_plugin.import_utils.device_operations.Site") - def test_duplicate_detection_uses_resolved_name(self, *mocks): - """Duplicate detection should match against the resolved name, not raw hostname.""" - self._setup_no_existing(mocks) - - mock_device = mocks[-2] # Device - # The first filter call (librenms_id) returns None, - # the second filter call (name__iexact) returns the existing device. - existing = MagicMock() - existing.name = "core-switch" - existing.serial = "" - mock_device.objects.filter.return_value.first.side_effect = [None, existing] - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 999, - "hostname": "10.0.0.1", - "sysName": "core-switch", - } - # use_sysname=True (default): resolved name is "core-switch" - # so duplicate detection should find existing device "core-switch" - result = validate_device_for_import(device_data, include_vc_detection=False) - - assert result["existing_device"] == existing - assert result["existing_match_type"] == "hostname" - - @patch("virtualization.models.VirtualMachine") - @patch("netbox_librenms_plugin.import_utils.device_operations.Device") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") - @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") - @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") - @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") - @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") - @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") - @patch("netbox_librenms_plugin.import_utils.device_operations.Site") - def test_backward_compatible_defaults(self, *mocks): - """Calling without naming params produces resolved_name in result.""" - self._setup_no_existing(mocks) - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "switch-01", - } - result = validate_device_for_import(device_data, include_vc_detection=False) - - # resolved_name should be present and match sysName fallback to hostname - assert "resolved_name" in result - assert result["resolved_name"] == "switch-01" - - -class TestNameMatchesWithNamingPreferences: - """Test that name_matches/name_sync_available respect naming preferences and VC patterns. - - The name comparison should use the resolved name (result of _determine_device_name()) - which accounts for use_sysname and strip_domain, not the raw LibreNMS sysName. - For VC members, it should also account for the VC naming pattern. - """ +class TestSerialNumberMatching: + """Test serial number matching in device validation.""" - COMMON_PATCHES = [ + SERIAL_PATCHES = [ "netbox_librenms_plugin.import_utils.device_operations.Site", "netbox_librenms_plugin.import_utils.device_operations.Rack", "netbox_librenms_plugin.import_utils.device_operations.Cluster", "netbox_librenms_plugin.import_utils.device_operations.DeviceRole", + "netbox_librenms_plugin.import_utils.device_operations.DeviceType", "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", @@ -1308,381 +1132,79 @@ class TestNameMatchesWithNamingPreferences: def _start_patches(self): """Start all common patches and return mocks in standard order.""" - self._patchers = [patch(p) for p in self.COMMON_PATCHES] + self._patchers = [patch(p) for p in self.SERIAL_PATCHES] mocks = [p.start() for p in self._patchers] ( self.mock_site_model, self.mock_rack, self.mock_cluster, self.mock_role, + self.mock_device_type, self.mock_match_type, self.mock_find_platform, self.mock_find_site, self.mock_device, self.mock_vm, ) = mocks + self.mock_device_type.objects.all.return_value = [] def _stop_patches(self): """Stop all patches.""" for p in self._patchers: p.stop() - def _configure_standard_mocks(self): - """Configure standard mock returns for site/platform/type/role.""" - self.mock_find_site.return_value = { - "found": True, - "site": MagicMock(), - "match_type": "exact", - "confidence": 1.0, - } - self.mock_find_platform.return_value = {"found": False, "platform": None, "match_type": None} - self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} - self.mock_role.objects.all.return_value = [] - self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] - self.mock_site_model.objects.all.return_value = [] - - def _setup_librenms_id_match(self, existing_device, as_vm=False): - """Configure mocks so that a device is found by librenms_id.""" - if as_vm: - self.mock_vm.objects.filter.return_value.first.return_value = existing_device - self.mock_device.objects.filter.return_value.first.return_value = None - else: - self.mock_vm.objects.filter.return_value.first.return_value = None - - def device_filter(**kwargs): - result = MagicMock() - if "custom_field_data__librenms_id" in kwargs: - result.first.return_value = existing_device - else: - result.first.return_value = None - return result - - self.mock_device.objects.filter.side_effect = device_filter - def setup_method(self): - """Set up common patches.""" + """Set up common patches for serial number tests.""" self._start_patches() def teardown_method(self): """Tear down patches.""" self._stop_patches() - def test_name_matches_with_strip_domain(self): - """strip_domain=True: FQDN in LibreNMS matches short name in NetBox.""" + def test_serial_match_blocks_import(self): + """Device with matching serial blocks import.""" existing = MagicMock() - existing.name = "router" - existing.serial = "" - existing.virtual_chassis = None - existing.vc_position = None + existing.name = "existing-device" + existing.serial = "ABC123" + + self.mock_vm.objects.filter.return_value.first.return_value = None - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() + def device_filter(**kwargs): + result = MagicMock() + if "serial" in kwargs: + result.first.return_value = existing + else: + result.first.return_value = None + return result + + self.mock_device.objects.filter.side_effect = device_filter from netbox_librenms_plugin.import_utils import validate_device_for_import - device_data = { - "device_id": 1, - "hostname": "router.example.com", - "sysName": "router.example.com", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - strip_domain=True, - ) + device_data = {"device_id": 1, "hostname": "new-hostname", "serial": "ABC123"} + result = validate_device_for_import(device_data, include_vc_detection=False) - assert result["existing_match_type"] == "librenms_id" - assert result["name_matches"] is True - assert result["name_sync_available"] is False + assert result["can_import"] is False + assert result["existing_match_type"] == "serial" + assert result["existing_device"] == existing - def test_name_matches_uses_hostname_when_sysname_disabled(self): - """use_sysname=False: matches against hostname instead of sysName.""" + def test_serial_match_same_hostname_offers_link(self): + """Serial + hostname match offers link action.""" existing = MagicMock() - existing.name = "10.0.0.1" - existing.serial = "" - existing.virtual_chassis = None - existing.vc_position = None + existing.name = "switch-01" + existing.serial = "ABC123" - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "10.0.0.1", - "sysName": "core-switch", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - use_sysname=False, - ) - - assert result["name_matches"] is True - assert result["name_sync_available"] is False - - def test_name_mismatch_offers_sync_with_resolved_name(self): - """When names don't match, suggested_name is the resolved name, not raw sysName.""" - existing = MagicMock() - existing.name = "old-device" - existing.serial = "" - existing.virtual_chassis = None - existing.vc_position = None - - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "new-switch.example.com", - "sysName": "new-switch.example.com", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - strip_domain=True, - ) - - assert result["name_sync_available"] is True - # suggested_name should be the resolved (stripped) name, not raw sysName - assert result["suggested_name"] == "new-switch" - - @patch("netbox_librenms_plugin.import_utils.device_operations._generate_vc_member_name") - def test_name_matches_vc_member(self, mock_vc_name): - """VC member: name matches when existing device name matches generated VC name.""" - mock_vc_name.return_value = "switch-M2" - - existing = MagicMock() - existing.name = "switch-M2" - existing.serial = "SN123" - existing.virtual_chassis = MagicMock() # Not None β†’ device is a VC member - existing.vc_position = 2 - - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "switch", - "sysName": "switch", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - ) - - assert result["name_matches"] is True - assert result["name_sync_available"] is False - # _generate_vc_member_name should be called with resolved name, position, serial - mock_vc_name.assert_called_with("switch", 2, serial="SN123") - - @patch("netbox_librenms_plugin.import_utils.device_operations._generate_vc_member_name") - def test_name_matches_vc_member_with_strip_domain(self, mock_vc_name): - """VC member + strip_domain: FQDN resolved to short name matches VC pattern.""" - mock_vc_name.return_value = "siteA-9300-1 (2)" - - existing = MagicMock() - existing.name = "siteA-9300-1 (2)" - existing.serial = "SN456" - existing.virtual_chassis = MagicMock() - existing.vc_position = 2 - - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 555, - "hostname": "siteA-9300-1.example.net.com", - "sysName": "siteA-9300-1.example.net.com", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - strip_domain=True, - ) - - assert result["name_matches"] is True - assert result["name_sync_available"] is False - # Resolved name should be "siteA-9300-1" (stripped), then VC name generated - mock_vc_name.assert_called_with("siteA-9300-1", 2, serial="SN456") - - @patch("netbox_librenms_plugin.import_utils.device_operations._generate_vc_member_name") - def test_vc_member_name_mismatch_suggests_vc_name(self, mock_vc_name): - """VC member name mismatch: suggested_name should be the expected VC name.""" - mock_vc_name.return_value = "new-switch-M2" - - existing = MagicMock() - existing.name = "old-switch-M2" - existing.serial = "SN789" - existing.virtual_chassis = MagicMock() - existing.vc_position = 2 - - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "new-switch", - "sysName": "new-switch", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - ) - - assert result["name_matches"] is False - assert result["name_sync_available"] is True - assert result["suggested_name"] == "new-switch-M2" - - def test_vm_name_matches_with_strip_domain(self): - """VM name comparison also uses resolved name, not raw sysName.""" - existing_vm = MagicMock() - existing_vm.name = "vm-server" - - self._setup_librenms_id_match(existing_vm, as_vm=True) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "vm-server.example.com", - "sysName": "vm-server.example.com", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - strip_domain=True, - ) - - assert result["import_as_vm"] is True - assert result["name_matches"] is True - - def test_name_matches_exact_without_vc(self): - """Standalone device: exact name match works without VC check.""" - existing = MagicMock() - existing.name = "core-router" - existing.serial = "" - existing.virtual_chassis = None - existing.vc_position = None - - self._setup_librenms_id_match(existing) - self._configure_standard_mocks() - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = { - "device_id": 1, - "hostname": "core-router", - "sysName": "core-router", - } - result = validate_device_for_import( - device_data, - include_vc_detection=False, - ) - - assert result["name_matches"] is True - assert result["name_sync_available"] is False - - -class TestSerialNumberMatching: - """Test serial number matching in device validation.""" - - SERIAL_PATCHES = [ - "netbox_librenms_plugin.import_utils.device_operations.Site", - "netbox_librenms_plugin.import_utils.device_operations.Rack", - "netbox_librenms_plugin.import_utils.device_operations.Cluster", - "netbox_librenms_plugin.import_utils.device_operations.DeviceRole", - "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", - "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", - "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", - "netbox_librenms_plugin.import_utils.device_operations.Device", - "virtualization.models.VirtualMachine", - ] - - def _start_patches(self): - """Start all common patches and return mocks in standard order.""" - self._patchers = [patch(p) for p in self.SERIAL_PATCHES] - mocks = [p.start() for p in self._patchers] - ( - self.mock_site_model, - self.mock_rack, - self.mock_cluster, - self.mock_role, - self.mock_match_type, - self.mock_find_platform, - self.mock_find_site, - self.mock_device, - self.mock_vm, - ) = mocks - - def _stop_patches(self): - """Stop all patches.""" - for p in self._patchers: - p.stop() - - def setup_method(self): - """Set up common patches for serial number tests.""" - self._start_patches() - - def teardown_method(self): - """Tear down patches.""" - self._stop_patches() - - def test_serial_match_blocks_import(self): - """Device with matching serial blocks import.""" - existing = MagicMock() - existing.name = "existing-device" - existing.serial = "ABC123" - - self.mock_vm.objects.filter.return_value.first.return_value = None - - def device_filter(**kwargs): - result = MagicMock() - if "serial" in kwargs: - result.first.return_value = existing - else: - result.first.return_value = None - return result - - self.mock_device.objects.filter.side_effect = device_filter - - from netbox_librenms_plugin.import_utils import validate_device_for_import - - device_data = {"device_id": 1, "hostname": "new-hostname", "serial": "ABC123"} - result = validate_device_for_import(device_data, include_vc_detection=False) - - assert result["can_import"] is False - assert result["existing_match_type"] == "serial" - assert result["existing_device"] == existing - - def test_serial_match_same_hostname_offers_link(self): - """Serial + hostname match offers link action.""" - existing = MagicMock() - existing.name = "switch-01" - existing.serial = "ABC123" - - self.mock_vm.objects.filter.return_value.first.return_value = None - - def device_filter(**kwargs): - result = MagicMock() - if "serial" in kwargs: - result.first.return_value = existing - else: - result.first.return_value = None - return result - - self.mock_device.objects.filter.side_effect = device_filter + self.mock_vm.objects.filter.return_value.first.return_value = None + + def device_filter(**kwargs): + result = MagicMock() + if "serial" in kwargs: + result.first.return_value = existing + else: + result.first.return_value = None + return result + + self.mock_device.objects.filter.side_effect = device_filter from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1718,7 +1240,7 @@ def device_filter(**kwargs): assert result["serial_action"] == "hostname_differs" assert result["existing_match_type"] == "serial" - assert "reinstalled" in result["warnings"][0] + assert "hostname differs" in result["warnings"][0] def test_hostname_match_diff_serial_offers_update(self): """Hostname matches but serial differs offers update_serial action.""" @@ -1759,7 +1281,6 @@ def _setup_no_match_mocks(self): self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] def test_serial_dash_ignored(self): @@ -1835,12 +1356,17 @@ def test_librenms_id_match_shows_serial_confirmed(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "ABC123" + existing.virtual_chassis = None # Not a VC member β†’ use plain hostname comparison + existing.vc_position = None self.mock_vm.objects.filter.return_value.first.return_value = None - def device_filter(**kwargs): + def device_filter(*args, **kwargs): result = MagicMock() - if "custom_field_data__librenms_id" in kwargs: + q_has_librenms = any("librenms_id" in str(arg) for arg in args) or any( + k.startswith("custom_field_data__librenms_id") for k in kwargs + ) + if q_has_librenms: result.first.return_value = existing else: result.first.return_value = None @@ -1857,7 +1383,6 @@ def device_filter(**kwargs): self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1875,12 +1400,17 @@ def test_librenms_id_match_detects_serial_drift(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "OLD_SERIAL" + existing.virtual_chassis = None + existing.vc_position = None self.mock_vm.objects.filter.return_value.first.return_value = None - def device_filter(**kwargs): + def device_filter(*args, **kwargs): result = MagicMock() - if "custom_field_data__librenms_id" in kwargs: + q_has_librenms = any("librenms_id" in str(arg) for arg in args) or any( + k.startswith("custom_field_data__librenms_id") for k in kwargs + ) + if q_has_librenms: result.first.return_value = existing elif "serial" in kwargs: result.first.return_value = None @@ -1900,7 +1430,6 @@ def device_filter(**kwargs): self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1917,12 +1446,17 @@ def test_librenms_id_match_still_validates_site(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "" + existing.virtual_chassis = None + existing.vc_position = None self.mock_vm.objects.filter.return_value.first.return_value = None - def device_filter(**kwargs): + def device_filter(*args, **kwargs): result = MagicMock() - if "custom_field_data__librenms_id" in kwargs: + q_has_librenms = any("librenms_id" in str(arg) for arg in args) or any( + k.startswith("custom_field_data__librenms_id") for k in kwargs + ) + if q_has_librenms: result.first.return_value = existing else: result.first.return_value = None @@ -1936,7 +1470,6 @@ def device_filter(**kwargs): self.mock_match_type.return_value = {"matched": True, "device_type": mock_dt, "match_type": "exact"} self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] from netbox_librenms_plugin.import_utils import validate_device_for_import @@ -1957,6 +1490,8 @@ def test_existing_device_role_populated(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "ABC123" + existing.virtual_chassis = None + existing.vc_position = None mock_existing_role = MagicMock() mock_existing_role.name = "Access Switch" existing.role = mock_existing_role @@ -1977,7 +1512,6 @@ def device_filter(**kwargs): self.mock_match_type.return_value = {"matched": True, "device_type": MagicMock(), "match_type": "exact"} self.mock_role.objects.all.return_value = [mock_existing_role] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] with patch("netbox_librenms_plugin.import_utils.device_operations.cache") as mock_cache: @@ -2003,6 +1537,8 @@ def test_device_type_mismatch_flagged(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "ABC123" + existing.virtual_chassis = None + existing.vc_position = None existing_device_type = MagicMock() existing_device_type.pk = 1 existing_device_type.__str__ = lambda self: "Old Type" @@ -2033,7 +1569,6 @@ def device_filter(**kwargs): } self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] with patch("netbox_librenms_plugin.import_utils.device_operations.cache") as mock_cache: @@ -2058,6 +1593,8 @@ def test_no_device_type_mismatch_when_types_match(self): existing = MagicMock() existing.name = "switch-01" existing.serial = "ABC123" + existing.virtual_chassis = None + existing.vc_position = None same_device_type = MagicMock() same_device_type.pk = 1 existing.device_type = same_device_type @@ -2079,7 +1616,6 @@ def device_filter(**kwargs): self.mock_match_type.return_value = {"matched": True, "device_type": same_device_type, "match_type": "exact"} self.mock_role.objects.all.return_value = [] self.mock_cluster.objects.all.return_value = [] - self.mock_rack.objects.filter.return_value = [] self.mock_site_model.objects.all.return_value = [] with patch("netbox_librenms_plugin.import_utils.device_operations.cache") as mock_cache: @@ -2099,38 +1635,442 @@ def device_filter(**kwargs): assert result["device_type_mismatch"] is False -class TestDeviceConflictActionView: - """Test DeviceConflictActionView conflict resolution actions.""" - - def _create_view(self): - """Create a DeviceConflictActionView instance with mocked dependencies.""" - from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView - - view = object.__new__(DeviceConflictActionView) - view._librenms_api = MagicMock() - view._librenms_api.server_key = "default" - view.request = MagicMock() - view.request.user.has_perm.return_value = True - return view - - def _create_request(self, action, existing_device_id, use_sysname=False, strip_domain=False): - """Create a mock request with POST data.""" - request = MagicMock() - post_data = {"action": action, "existing_device_id": str(existing_device_id)} - if use_sysname: - post_data["use-sysname-toggle"] = "on" - if strip_domain: - post_data["strip-domain-toggle"] = "on" - request.POST = post_data - return request +class TestNameMatchesWithNamingPreferences: + """Test VC-aware name matching with use_sysname/strip_domain preferences.""" - @patch("netbox_librenms_plugin.views.imports.actions.cache") - @patch("netbox_librenms_plugin.views.imports.actions.get_import_device_cache_key") - def test_link_action_sets_librenms_id_and_name(self, mock_cache_key, mock_cache): - """Link action should set librenms_id and update name from sysName.""" - from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView + PATCHES = [ + "netbox_librenms_plugin.import_utils.device_operations.Site", + "netbox_librenms_plugin.import_utils.device_operations.Rack", + "netbox_librenms_plugin.import_utils.device_operations.Cluster", + "netbox_librenms_plugin.import_utils.device_operations.DeviceRole", + "netbox_librenms_plugin.import_utils.device_operations.DeviceType", + "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", + "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", + "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", + "netbox_librenms_plugin.import_utils.device_operations.Device", + "virtualization.models.VirtualMachine", + ] - view = self._create_view() + def setup_method(self): + self._patchers = [patch(p) for p in self.PATCHES] + mocks = [p.start() for p in self._patchers] + ( + self.mock_site, + self.mock_rack, + self.mock_cluster, + self.mock_role, + self.mock_device_type, + self.mock_match_type, + self.mock_find_platform, + self.mock_find_site, + self.mock_device, + self.mock_vm, + ) = mocks + self.mock_device_type.objects.all.return_value = [] + self.mock_vm.objects.filter.return_value.first.return_value = None + self.mock_find_site.return_value = { + "found": True, + "site": MagicMock(), + "match_type": "exact", + "confidence": 1.0, + } + self.mock_find_platform.return_value = {"found": False, "platform": None, "match_type": None} + self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} + self.mock_role.objects.all.return_value = [] + self.mock_cluster.objects.all.return_value = [] + self.mock_site.objects.all.return_value = [] + + def teardown_method(self): + for p in self._patchers: + p.stop() + + def _make_existing(self, name, serial="SN001", virtual_chassis=None, vc_position=None): + existing = MagicMock() + existing.name = name + existing.serial = serial + existing.virtual_chassis = virtual_chassis + existing.vc_position = vc_position + existing.custom_field_data = {"librenms_id": {"default": 42}} + return existing + + def _setup_librenms_id_filter(self, existing): + def device_filter(*args, **kwargs): + result = MagicMock() + q_has_librenms = any("librenms_id" in str(arg) for arg in args) or any( + k.startswith("custom_field_data__librenms_id") for k in kwargs + ) + result.first.return_value = existing if q_has_librenms else None + result.exclude.return_value.first.return_value = None + return result + + self.mock_device.objects.filter.side_effect = device_filter + + def test_strip_domain_name_matches(self): + """strip_domain=True resolves 'switch-01.example.com' to 'switch-01', matching existing device.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + existing = self._make_existing("switch-01") + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "switch-01.example.com", + "sysName": "switch-01.example.com", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False, strip_domain=True) + assert result["name_matches"] is True + assert result["name_sync_available"] is False + + def test_sysname_disabled_uses_hostname(self): + """use_sysname=False falls back to hostname for name comparison.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + existing = self._make_existing("switch-hostname") + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "switch-hostname", + "sysName": "switch-sysname", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False, use_sysname=False) + assert result["name_matches"] is True + assert result["resolved_name"] == "switch-hostname" + + def test_name_mismatch_offers_sync(self): + """When resolved name differs from existing device name, name_sync_available is set.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + existing = self._make_existing("old-name") + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "new-name", + "sysName": "new-name", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + assert result["name_matches"] is False + assert result["name_sync_available"] is True + assert result["suggested_name"] == "new-name" + + def test_vc_member_name_matches(self): + """Existing VC member name is compared against vc_member_name(hostname, vc_position).""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + mock_vc = MagicMock() + expected_name = _generate_vc_member_name("stack-master", 2, serial="SN001") + existing = self._make_existing(expected_name, serial="SN001", virtual_chassis=mock_vc, vc_position=2) + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "stack-master", + "sysName": "stack-master", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + assert result["name_matches"] is True + + def test_vc_member_name_mismatch_suggests_vc_name(self): + """When VC member name differs, suggested_name is the expected VC member name.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + mock_vc = MagicMock() + existing = self._make_existing("wrong-name", serial="SN001", virtual_chassis=mock_vc, vc_position=2) + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "stack-master", + "sysName": "stack-master", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + expected_name = _generate_vc_member_name("stack-master", 2, serial="SN001") + assert result["name_matches"] is False + assert result["name_sync_available"] is True + assert result["suggested_name"] == expected_name + + def test_vc_member_with_strip_domain(self): + """strip_domain applies before VC member name comparison.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + mock_vc = MagicMock() + expected_name = _generate_vc_member_name("stack", 1, serial="SN001") + existing = self._make_existing(expected_name, serial="SN001", virtual_chassis=mock_vc, vc_position=1) + self._setup_librenms_id_filter(existing) + + device_data = { + "device_id": 42, + "hostname": "stack.example.com", + "sysName": "stack.example.com", + "serial": "SN001", + } + result = validate_device_for_import(device_data, include_vc_detection=False, strip_domain=True) + assert result["name_matches"] is True + + def test_naming_criteria_populated(self): + """naming_criteria dict is set in result with use_sysname/strip_domain/source.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + self.mock_device.objects.filter.return_value.first.return_value = None + + device_data = { + "device_id": 99, + "hostname": "router-01", + "sysName": "router-sysname", + } + result = validate_device_for_import( + device_data, include_vc_detection=False, use_sysname=True, strip_domain=False + ) + criteria = result["naming_criteria"] + assert criteria is not None + assert criteria["use_sysname"] is True + assert criteria["strip_domain"] is False + assert criteria["raw_sysname"] == "router-sysname" + assert criteria["raw_hostname"] == "router-01" + assert criteria["source"] == "sysname" + + def test_naming_criteria_source_hostname_when_sysname_disabled(self): + """naming_criteria source is 'hostname' when use_sysname=False.""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + self.mock_device.objects.filter.return_value.first.return_value = None + + device_data = { + "device_id": 99, + "hostname": "router-01", + "sysName": "router-sysname", + } + result = validate_device_for_import( + device_data, include_vc_detection=False, use_sysname=False, strip_domain=False + ) + assert result["naming_criteria"]["source"] == "hostname" + + def test_naming_criteria_source_sysname_when_sysname_disabled_but_hostname_empty(self): + """When use_sysname=False and hostname is empty, source falls back to 'sysname'. + + Before the fix, source was incorrectly reported as 'hostname' even + though the resolved name actually came from sysName. + """ + from netbox_librenms_plugin.import_utils import validate_device_for_import + + self.mock_device.objects.filter.return_value.first.return_value = None + + device_data = { + "device_id": 99, + "hostname": "", + "sysName": "router-sysname", + } + result = validate_device_for_import( + device_data, include_vc_detection=False, use_sysname=False, strip_domain=False + ) + assert result["naming_criteria"]["source"] == "sysname", ( + "When hostname is empty, source must be 'sysname', not 'hostname'" + ) + + def test_naming_criteria_source_hostname_fallback_when_both_empty(self): + """When both hostname and sysName are empty, source is 'hostname' (final fallback).""" + from netbox_librenms_plugin.import_utils import validate_device_for_import + + self.mock_device.objects.filter.return_value.first.return_value = None + + device_data = {"device_id": 99, "hostname": "", "sysName": ""} + result = validate_device_for_import( + device_data, include_vc_detection=False, use_sysname=False, strip_domain=False + ) + # Both empty β†’ final fallback is 'hostname' + assert result["naming_criteria"]["source"] == "hostname" + + +class TestLegacyLibreNMSIdMigration: + """Test detection of legacy bare-integer librenms_id format during device validation.""" + + PATCHES = [ + "netbox_librenms_plugin.import_utils.device_operations.Site", + "netbox_librenms_plugin.import_utils.device_operations.Rack", + "netbox_librenms_plugin.import_utils.device_operations.Cluster", + "netbox_librenms_plugin.import_utils.device_operations.DeviceRole", + "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", + "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", + "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", + "netbox_librenms_plugin.import_utils.device_operations.Device", + "virtualization.models.VirtualMachine", + ] + + def setup_method(self): + self._patchers = [patch(p) for p in self.PATCHES] + mocks = [p.start() for p in self._patchers] + ( + self.mock_site_model, + self.mock_rack, + self.mock_cluster, + self.mock_role, + self.mock_match_type, + self.mock_find_platform, + self.mock_find_site, + self.mock_device, + self.mock_vm, + ) = mocks + + self.mock_find_site.return_value = { + "found": True, + "site": MagicMock(), + "match_type": "exact", + "confidence": 1.0, + } + self.mock_find_platform.return_value = {"found": False, "platform": None, "match_type": None} + self.mock_match_type.return_value = {"matched": False, "device_type": None, "match_type": None} + self.mock_role.objects.all.return_value = [] + self.mock_cluster.objects.all.return_value = [] + self.mock_site_model.objects.all.return_value = [] + self.mock_vm.objects.filter.return_value.first.return_value = None + + def teardown_method(self): + for p in self._patchers: + p.stop() + + def _make_existing(self, librenms_id_value, serial="SN001"): + existing = MagicMock() + existing.name = "switch-01" + existing.serial = serial + existing.virtual_chassis = None + existing.vc_position = None + existing.custom_field_data = {"librenms_id": librenms_id_value} + return existing + + def _setup_device_filter(self, existing): + def device_filter(*args, **kwargs): + result = MagicMock() + q_has_librenms = any("librenms_id" in str(arg) for arg in args) or any( + k.startswith("custom_field_data__librenms_id") for k in kwargs + ) + result.first.return_value = existing if q_has_librenms else None + return result + + self.mock_device.objects.filter.side_effect = device_filter + + def test_legacy_int_sets_needs_migration_flag(self): + """Device with bare-integer librenms_id sets librenms_id_needs_migration=True.""" + existing = self._make_existing(librenms_id_value=42, serial="SN001") + self._setup_device_filter(existing) + + from netbox_librenms_plugin.import_utils import validate_device_for_import + + result = validate_device_for_import( + {"device_id": 42, "hostname": "switch-01", "serial": "SN001"}, + include_vc_detection=False, + ) + + assert result["existing_match_type"] == "librenms_id" + assert result["librenms_id_needs_migration"] is True + assert result["serial_confirmed"] is True + + def test_legacy_int_no_serial_still_sets_flag(self): + """Legacy int format sets the migration flag even when serial is absent.""" + existing = self._make_existing(librenms_id_value=42, serial="") + self._setup_device_filter(existing) + + from netbox_librenms_plugin.import_utils import validate_device_for_import + + result = validate_device_for_import( + {"device_id": 42, "hostname": "switch-01"}, + include_vc_detection=False, + ) + + assert result["librenms_id_needs_migration"] is True + assert result["serial_confirmed"] is False + + def test_json_format_does_not_set_flag(self): + """Device with JSON librenms_id does NOT set librenms_id_needs_migration.""" + existing = self._make_existing(librenms_id_value={"default": 42}, serial="SN001") + self._setup_device_filter(existing) + + from netbox_librenms_plugin.import_utils import validate_device_for_import + + result = validate_device_for_import( + {"device_id": 42, "hostname": "switch-01", "serial": "SN001"}, + include_vc_detection=False, + ) + + assert result["existing_match_type"] == "librenms_id" + assert result["librenms_id_needs_migration"] is False + + def test_migrate_legacy_librenms_id_helper(self): + """migrate_legacy_librenms_id converts int to {server_key: int}.""" + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 42} + result = migrate_legacy_librenms_id(obj, "primary") + + assert result is True + assert obj.custom_field_data["librenms_id"] == {"primary": 42} + + def test_migrate_legacy_librenms_id_noop_for_json(self): + """migrate_legacy_librenms_id is a no-op when value is already a dict.""" + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + result = migrate_legacy_librenms_id(obj, "primary") + + assert result is False + assert obj.custom_field_data["librenms_id"] == {"primary": 42} + + def test_migrate_legacy_librenms_id_noop_for_none(self): + """migrate_legacy_librenms_id is a no-op when librenms_id is absent.""" + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {} + result = migrate_legacy_librenms_id(obj, "primary") + + assert result is False + + +class TestDeviceConflictActionView: + """Test DeviceConflictActionView conflict resolution actions.""" + + def _create_view(self): + """Create a DeviceConflictActionView instance with mocked dependencies.""" + from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView + + view = DeviceConflictActionView() + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view.request = MagicMock() + view.request.user.has_perm.return_value = True + return view + + def _create_request(self, action, existing_device_id, use_sysname=False, strip_domain=False): + """Create a mock request with POST data.""" + request = MagicMock() + # Always include both toggles so _resolve_naming_preferences never falls through + # to the user-pref/settings DB path, which would hit the real database. + post_data = { + "action": action, + "existing_device_id": str(existing_device_id), + "use-sysname-toggle": "on" if use_sysname else "off", + "strip-domain-toggle": "on" if strip_domain else "off", + } + request.POST = post_data + return request + + @patch("netbox_librenms_plugin.views.imports.actions.cache") + @patch("netbox_librenms_plugin.views.imports.actions.get_import_device_cache_key") + def test_link_action_sets_librenms_id_and_name(self, mock_cache_key, mock_cache): + """Link action should set librenms_id and update name from sysName.""" + from netbox_librenms_plugin.views.imports.actions import DeviceConflictActionView + + view = self._create_view() existing_device = MagicMock() existing_device.pk = 42 existing_device.custom_field_data = {} @@ -2151,15 +2091,21 @@ def test_link_action_sets_librenms_id_and_name(self, mock_cache_key, mock_cache) patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() view.post(request, device_id=10) - assert existing_device.custom_field_data["librenms_id"] == 10 + assert existing_device.custom_field_data["librenms_id"] == {"default": 10} assert existing_device.name == "switch-01.example.com" existing_device.save.assert_called_once() @@ -2191,15 +2137,21 @@ def test_update_action_sets_hostname_serial_and_librenms_id(self, mock_cache_key patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() view.post(request, device_id=10) - assert existing_device.custom_field_data["librenms_id"] == 10 + assert existing_device.custom_field_data["librenms_id"] == {"default": 10} assert existing_device.serial == "NEW-SERIAL" assert existing_device.name == "new-name.example.com" existing_device.save.assert_called_once() @@ -2227,15 +2179,21 @@ def test_update_serial_action_updates_serial_only(self, mock_cache_key, mock_cac patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() view.post(request, device_id=10) - assert existing_device.custom_field_data["librenms_id"] == 10 + assert existing_device.custom_field_data["librenms_id"] == {"default": 10} assert existing_device.serial == "NEW-SERIAL" # Name should NOT be changed by update_serial assert existing_device.name == "switch-01" @@ -2264,8 +2222,13 @@ def test_update_skips_dash_serial(self, mock_cache_key, mock_cache): patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2291,14 +2254,20 @@ def test_unknown_action_returns_400(self): request = self._create_request("invalid_action", 42) existing_device = MagicMock() + existing_device.pk = 42 libre_device = {"device_id": 10, "hostname": "switch-01", "serial": "ABC"} with ( patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, + patch.object(DeviceConflictActionView, "require_object_permissions", return_value=None), patch("dcim.models.Device") as mock_device_cls, ): mock_device_cls.objects.get.return_value = existing_device - mock_validate.return_value = (libre_device, {}, {}) + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + # Include existing_device so the validated-conflict-target guard passes; + # we want to exercise the unknown-action branch, not the missing-device guard. + mock_validate.return_value = (libre_device, {"existing_device": existing_device}, {}) response = view.post(request, device_id=10) @@ -2333,6 +2302,8 @@ def test_sync_name_action_updates_name(self, mock_cache_key, mock_cache): patch("dcim.models.Device") as mock_device_cls, ): mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2363,6 +2334,8 @@ def test_device_type_mismatch_blocked_without_force(self, mock_cache_key, mock_c patch("dcim.models.Device") as mock_device_cls, ): mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None mock_validate.return_value = (libre_device, validation, selections) response = view.post(request, device_id=10) @@ -2398,15 +2371,21 @@ def test_device_type_mismatch_allowed_with_force(self, mock_cache_key, mock_cach patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() view.post(request, device_id=10) - assert existing_device.custom_field_data["librenms_id"] == 10 + assert existing_device.custom_field_data["librenms_id"] == {"default": 10} existing_device.save.assert_called_once() @patch("netbox_librenms_plugin.views.imports.actions.cache") @@ -2444,16 +2423,22 @@ def test_force_with_mismatch_updates_device_type(self, mock_cache_key, mock_cach patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() view.post(request, device_id=10) assert existing_device.device_type == librenms_device_type - assert existing_device.custom_field_data["librenms_id"] == 10 + assert existing_device.custom_field_data["librenms_id"] == {"default": 10} existing_device.save.assert_called_once() @patch("netbox_librenms_plugin.views.imports.actions.cache") @@ -2494,6 +2479,8 @@ def test_update_type_action_changes_device_type(self, mock_cache_key, mock_cache patch("dcim.models.Device") as mock_device_cls, ): mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2521,9 +2508,15 @@ def test_sync_serial_action(self, mock_cache_key, mock_cache): patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.imports.actions.transaction") as mock_tx, ): + mock_tx.atomic.return_value = MagicMock() mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + 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 + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2552,10 +2545,14 @@ def test_sync_platform_action(self, mock_cache_key, mock_cache): patch.object(DeviceConflictActionView, "get_validated_device_with_selections") as mock_validate, patch.object(DeviceConflictActionView, "render_device_row") as mock_render, patch("dcim.models.Device") as mock_device_cls, - patch("dcim.models.Platform") as mock_platform_cls, + # Patch find_matching_platform at the utility module level β€” the action imports + # it from netbox_librenms_plugin.utils, so that is the correct seam to mock. + patch("netbox_librenms_plugin.utils.find_matching_platform") as mock_find_platform, ): mock_device_cls.objects.get.return_value = existing_device - mock_platform_cls.objects.get.return_value = mock_platform + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None + mock_find_platform.return_value = {"found": True, "platform": mock_platform, "match_type": "exact"} mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2586,6 +2583,8 @@ def test_sync_device_type_action(self, mock_cache_key, mock_cache): patch("netbox_librenms_plugin.utils.match_librenms_hardware_to_device_type") as mock_hw_match, ): mock_device_cls.objects.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.get.return_value = existing_device + mock_device_cls.objects.select_for_update.return_value.filter.return_value.exclude.return_value.first.return_value = None mock_hw_match.return_value = {"matched": True, "device_type": new_device_type} mock_validate.return_value = (libre_device, validation, selections) mock_render.return_value = MagicMock() @@ -2672,23 +2671,2233 @@ def test_platform_out_of_sync(self): assert result["platform_synced"] is False assert result["all_synced"] is False - def test_hardware_no_match_device_type_out_of_sync(self): - """When hardware is present but no device type match found, device_type_synced is False.""" + def test_platform_no_match_found_returns_bool(self): + """When find_matching_platform returns no match, platform_synced must be False (not None).""" from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView existing = MagicMock() existing.serial = "ABC123" - existing.platform = None + existing.platform = MagicMock() # device has a platform set device_type = MagicMock() device_type.pk = 5 existing.device_type = device_type - libre_device = {"serial": "ABC123", "os": "-", "hardware": "UnknownHardwareXYZ"} + libre_device = {"serial": "ABC123", "os": "ios", "hardware": "-"} - with patch("netbox_librenms_plugin.utils.match_librenms_hardware_to_device_type") as mock_hw_match: - mock_hw_match.return_value = {"matched": False, "device_type": None} + with patch("netbox_librenms_plugin.utils.find_matching_platform") as mock_platform_match: + mock_platform_match.return_value = {"found": False, "platform": None} result = DeviceValidationDetailsView._build_sync_info(libre_device, existing) - assert result["device_type_synced"] is False - assert result["all_synced"] is False + # Without bool() cast this would be None; verify it's exactly False (type-stable) + assert result["platform_synced"] is False + assert isinstance(result["platform_synced"], bool) + + def test_platform_synced_no_netbox_platform_returns_bool(self): + """When device has no platform in NetBox and os is non-dash, platform_synced must be bool.""" + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + existing = MagicMock() + existing.serial = "ABC123" + existing.platform = None # no platform on device + device_type = MagicMock() + device_type.pk = 5 + existing.device_type = device_type + + libre_device = {"serial": "ABC123", "os": "eos", "hardware": "-"} + + with patch("netbox_librenms_plugin.utils.find_matching_platform") as mock_platform_match: + mock_platform_match.return_value = {"found": True, "platform": MagicMock()} + + result = DeviceValidationDetailsView._build_sync_info(libre_device, existing) + + # None and ... returns None; bool() cast ensures False + assert result["platform_synced"] is False + assert isinstance(result["platform_synced"], bool) + + def test_hardware_no_match_device_type_out_of_sync(self): + """When hardware is present but no device type match found, device_type_synced is False.""" + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + existing = MagicMock() + existing.serial = "ABC123" + existing.platform = None + device_type = MagicMock() + device_type.pk = 5 + existing.device_type = device_type + + libre_device = {"serial": "ABC123", "os": "-", "hardware": "UnknownHardwareXYZ"} + + with patch("netbox_librenms_plugin.utils.match_librenms_hardware_to_device_type") as mock_hw_match: + mock_hw_match.return_value = {"matched": False, "device_type": None} + + result = DeviceValidationDetailsView._build_sync_info(libre_device, existing) + + assert result["device_type_synced"] is False + assert result["all_synced"] is False + + +class TestDeviceNamingPreferences: + """Test that validation honours use_sysname and strip_domain user preferences.""" + + def _setup_no_existing(self, mocks): + """Configure mocks so no existing device is found.""" + ( + mock_site_model, + mock_rack, + mock_cluster, + mock_role, + mock_match_type, + mock_find_platform, + mock_find_site, + mock_device, + mock_vm, + ) = mocks + + mock_vm.objects.filter.return_value.first.return_value = None + mock_device.objects.filter.return_value.first.return_value = None + mock_find_site.return_value = { + "found": False, + "site": None, + "match_type": None, + "confidence": 0.0, + } + mock_find_platform.return_value = { + "found": False, + "platform": None, + "match_type": None, + } + mock_match_type.return_value = { + "matched": False, + "device_type": None, + "match_type": None, + } + mock_role.objects.all.return_value = [] + mock_site_model.objects.all.return_value = [] + + @patch("virtualization.models.VirtualMachine") + @patch("netbox_librenms_plugin.import_utils.device_operations.Device") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") + @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") + @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") + @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") + @patch("netbox_librenms_plugin.import_utils.device_operations.Site") + def test_resolved_name_uses_sysname_by_default(self, *mocks): + """Default use_sysname=True uses sysName for resolved_name.""" + self._setup_no_existing(mocks) + from netbox_librenms_plugin.import_utils import validate_device_for_import + + device_data = { + "device_id": 1, + "hostname": "10.0.0.1", + "sysName": "core-switch", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + assert result["resolved_name"] == "core-switch" + + @patch("virtualization.models.VirtualMachine") + @patch("netbox_librenms_plugin.import_utils.device_operations.Device") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") + @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") + @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") + @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") + @patch("netbox_librenms_plugin.import_utils.device_operations.Site") + def test_resolved_name_uses_hostname_when_sysname_disabled(self, *mocks): + """use_sysname=False uses hostname for resolved_name.""" + self._setup_no_existing(mocks) + from netbox_librenms_plugin.import_utils import validate_device_for_import + + device_data = { + "device_id": 1, + "hostname": "10.0.0.1", + "sysName": "core-switch", + } + result = validate_device_for_import( + device_data, + include_vc_detection=False, + use_sysname=False, + ) + assert result["resolved_name"] == "10.0.0.1" + + @patch("virtualization.models.VirtualMachine") + @patch("netbox_librenms_plugin.import_utils.device_operations.Device") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") + @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") + @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") + @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") + @patch("netbox_librenms_plugin.import_utils.device_operations.Site") + def test_resolved_name_strips_domain(self, *mocks): + """strip_domain=True strips the domain suffix.""" + self._setup_no_existing(mocks) + from netbox_librenms_plugin.import_utils import validate_device_for_import + + device_data = { + "device_id": 1, + "hostname": "switch-01.example.com", + "sysName": "switch-01.example.com", + } + result = validate_device_for_import( + device_data, + include_vc_detection=False, + strip_domain=True, + ) + assert result["resolved_name"] == "switch-01" + + @patch("virtualization.models.VirtualMachine") + @patch("netbox_librenms_plugin.import_utils.device_operations.Device") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") + @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") + @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") + @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") + @patch("netbox_librenms_plugin.import_utils.device_operations.Site") + def test_duplicate_detection_uses_resolved_name(self, *mocks): + """Duplicate detection should match against the resolved name, not raw hostname.""" + self._setup_no_existing(mocks) + + # Unpack using same order as _setup_no_existing / @patch decorators (bottom-up) + ( + _mock_site, + _mock_rack, + _mock_cluster, + _mock_role, + _mock_hw, + _mock_platform, + _mock_find_site, + mock_device, + _mock_vm, + ) = mocks + existing = MagicMock() + existing.name = "core-switch" + existing.serial = "" + existing.virtual_chassis = None + existing.vc_position = None + mock_device.objects.filter.return_value.first.side_effect = [None, existing] + + from netbox_librenms_plugin.import_utils import validate_device_for_import + + device_data = { + "device_id": 999, + "hostname": "10.0.0.1", + "sysName": "core-switch", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + + assert result["existing_device"] == existing + assert result["existing_match_type"] == "hostname" + + @patch("virtualization.models.VirtualMachine") + @patch("netbox_librenms_plugin.import_utils.device_operations.Device") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_site") + @patch("netbox_librenms_plugin.import_utils.device_operations.find_matching_platform") + @patch("netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole") + @patch("netbox_librenms_plugin.import_utils.device_operations.Cluster") + @patch("netbox_librenms_plugin.import_utils.device_operations.Rack") + @patch("netbox_librenms_plugin.import_utils.device_operations.Site") + def test_backward_compatible_defaults(self, *mocks): + """Calling without naming params produces resolved_name in result.""" + self._setup_no_existing(mocks) + from netbox_librenms_plugin.import_utils import validate_device_for_import + + device_data = { + "device_id": 1, + "hostname": "switch-01", + } + result = validate_device_for_import(device_data, include_vc_detection=False) + assert "resolved_name" in result + assert result["resolved_name"] == "switch-01" + + +class TestProcessDeviceFilters: + """Tests for process_device_filters and related bulk_import utilities.""" + + def test_show_disabled_filters_integer_disabled_1(self): + """show_disabled=False should exclude devices with disabled==1 (int).""" + from unittest.mock import MagicMock, patch + + devices = [ + {"device_id": 1, "hostname": "a", "disabled": 0, "status": 1}, + {"device_id": 2, "hostname": "b", "disabled": 1, "status": 1}, # disabled in LibreNMS + ] + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=(devices, False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=lambda d, **kw: { + "resolved_name": d["hostname"], + "is_ready": True, + "can_import": True, + "status": "active", + "existing_device": None, + "import_as_vm": False, + "existing_match_type": None, + }, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices"), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache"), + patch("netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", return_value="key"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", return_value="vkey" + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + api = MagicMock() + api.server_key = "default" + result = process_device_filters( + api, filters={}, vc_detection_enabled=False, clear_cache=False, show_disabled=False + ) + + # Only enabled device (disabled==0) should be processed + assert len(result) == 1 + assert result[0]["hostname"] == "a" + + def test_show_disabled_keeps_unreachable_enabled_device(self): + """show_disabled=False should keep devices that are enabled (disabled==0) even if status==0.""" + from unittest.mock import MagicMock, patch + + devices = [ + {"device_id": 1, "hostname": "a", "disabled": 0, "status": 0}, # down but enabled + {"device_id": 2, "hostname": "b", "disabled": 1, "status": 0}, # down and disabled + ] + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=(devices, False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=lambda d, **kw: { + "resolved_name": d["hostname"], + "is_ready": True, + "can_import": True, + "status": "active", + "existing_device": None, + "import_as_vm": False, + "existing_match_type": None, + }, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices"), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache"), + patch("netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", return_value="key"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", return_value="vkey" + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + api = MagicMock() + api.server_key = "default" + result = process_device_filters( + api, filters={}, vc_detection_enabled=False, clear_cache=False, show_disabled=False + ) + + # Device a is enabled (disabled==0) and should be kept even though status==0 + assert len(result) == 1 + assert result[0]["hostname"] == "a" + + def test_show_disabled_true_includes_all(self): + """show_disabled=True should include both active and inactive devices.""" + from unittest.mock import MagicMock, patch + + devices = [ + {"device_id": 1, "hostname": "a", "status": 1}, + {"device_id": 2, "hostname": "b", "status": 0}, + ] + with ( + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_librenms_devices_for_import", + return_value=(devices, False), + ), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.validate_device_for_import", + side_effect=lambda d, **kw: { + "resolved_name": d["hostname"], + "is_ready": True, + "can_import": True, + "status": "active", + "existing_device": None, + "import_as_vm": False, + "existing_match_type": None, + }, + ), + patch("netbox_librenms_plugin.import_utils.bulk_import.prefetch_vc_data_for_devices"), + patch("netbox_librenms_plugin.import_utils.bulk_import.cache"), + patch("netbox_librenms_plugin.import_utils.bulk_import.get_cache_metadata_key", return_value="key"), + patch( + "netbox_librenms_plugin.import_utils.bulk_import.get_validated_device_cache_key", return_value="vkey" + ), + ): + from netbox_librenms_plugin.import_utils.bulk_import import process_device_filters + + api = MagicMock() + api.server_key = "default" + result = process_device_filters( + api, filters={}, vc_detection_enabled=False, clear_cache=False, show_disabled=True + ) + + assert len(result) == 2 + + def test_empty_return_helper(self): + """_empty_return should return ([], False) when return_cache_status=True, else [].""" + from netbox_librenms_plugin.import_utils.bulk_import import _empty_return + + assert _empty_return(True) == ([], False) + assert _empty_return(False) == [] + + def test_bulk_import_devices_uses_resolved_server_key(self): + """bulk_import_devices_shared should pass api.server_key to import_single_device.""" + from unittest.mock import MagicMock, patch + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") as mock_api_cls, + 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"), + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + ): + mock_api = MagicMock() + mock_api.server_key = "resolved-key" + mock_api.get_device_info.return_value = (True, {"device_id": 1, "hostname": "sw"}) + mock_api_cls.return_value = mock_api + mock_import.return_value = {"success": True, "device": MagicMock(), "is_vm": False} + + user = MagicMock() + from netbox_librenms_plugin.import_utils.bulk_import import bulk_import_devices_shared + + bulk_import_devices_shared([1], user=user, server_key=None) + + # The resolved api.server_key ("resolved-key") must be passed, not None + assert mock_import.call_args is not None + assert mock_import.call_args.kwargs.get("server_key") == "resolved-key" + + +class TestVCPositionHandling: + """Test VC position normalization and suggested name generation.""" + + def test_clone_vc_data_position_fallback_is_one_based(self): + """_clone_virtual_chassis_data fallback must be 1-based (idx+1, not idx).""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = {"is_stack": True, "member_count": 2, "members": [{"serial": "S1"}, {"serial": "S2"}]} + result = _clone_virtual_chassis_data(data) + positions = [m["position"] for m in result["members"]] + # First member: idx=0 β†’ position should be 1, not 0 + assert positions[0] == 1 + assert positions[1] == 2 + + def test_clone_vc_data_preserves_explicit_positions(self): + """_clone_virtual_chassis_data must preserve explicitly set positions.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S1", "position": 3}, {"serial": "S2", "position": 5}], + } + result = _clone_virtual_chassis_data(data) + assert result["members"][0]["position"] == 3 + assert result["members"][1]["position"] == 5 + + def test_clone_vc_data_bad_position_falls_back_to_one_based(self): + """_clone_virtual_chassis_data falls back to idx+1 for non-int position.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S1", "position": "bad"}, {"serial": "S2", "position": None}], + } + result = _clone_virtual_chassis_data(data) + # idx=0 β†’ fallback 1, idx=1 β†’ fallback 2 + assert result["members"][0]["position"] == 1 + assert result["members"][1]["position"] == 2 + + def test_suggested_name_uses_position_directly(self): + """Suggested name generation must use position directly (not position+1). + + This test verifies that _generate_vc_member_name is called with the + already-1-based position value, not position+1. + """ + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + # position=1 should produce name with "1", not "2" + name = _generate_vc_member_name("switch-1", 1, pattern="-M{position}") + assert name == "switch-1-M1", f"Expected 'switch-1-M1', got '{name}'" + + # position=2 should produce "2", not "3" + name = _generate_vc_member_name("switch-1", 2, pattern="-M{position}") + assert name == "switch-1-M2", f"Expected 'switch-1-M2', got '{name}'" + + def test_update_vc_member_suggested_names_no_off_by_one(self): + """update_vc_member_suggested_names must use stored 1-based positions directly. + + Previously bays_by_depth applied an extra +1 to positions that were + already 1-based, producing suggested names like "switch-M2" for position 1. + """ + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import ( + update_vc_member_suggested_names, + ) + + vc_data = { + "is_stack": True, + "member_count": 2, + "members": [ + {"serial": "S1", "position": 1}, + {"serial": "S2", "position": 2}, + ], + } + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = update_vc_member_suggested_names(vc_data, "switch-01") + + names = [m["suggested_name"] for m in result["members"]] + # Position 1 β†’ "switch-01-M1", NOT "switch-01-M2" + assert names[0] == "switch-01-M1", f"Expected 'switch-01-M1' but got {names[0]!r} β€” off-by-one regression" + assert names[1] == "switch-01-M2", f"Expected 'switch-01-M2' but got {names[1]!r} β€” off-by-one regression" + + def test_update_vc_member_suggested_names_preserves_position(self): + """update_vc_member_suggested_names must write final position back to member dict.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import ( + update_vc_member_suggested_names, + ) + + vc_data = { + "is_stack": True, + "member_count": 1, + "members": [{"serial": "S1", "position": 3}], + } + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = update_vc_member_suggested_names(vc_data, "router") + + member = result["members"][0] + assert member["position"] == 3 + assert member["suggested_name"] == "router-M3" + + def test_update_vc_member_suggested_names_fallback_for_zero_position(self): + """Position 0 must be replaced with 1-based fallback (idx+1).""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import ( + update_vc_member_suggested_names, + ) + + vc_data = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S1", "position": 0}, {"serial": "S2", "position": -1}], + } + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = update_vc_member_suggested_names(vc_data, "sw") + + positions = [m["position"] for m in result["members"]] + assert positions[0] == 1, f"Zero position must fall back to 1, got {positions[0]}" + assert positions[1] == 2, f"Negative position must fall back to 2 (idx+1), got {positions[1]}" + + +# --------------------------------------------------------------------------- +# Additional virtual_chassis.py coverage +# --------------------------------------------------------------------------- + + +class TestEmptyVirtualChassisData: + """Tests for empty_virtual_chassis_data helper.""" + + def test_returns_expected_structure(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import empty_virtual_chassis_data + + result = empty_virtual_chassis_data() + assert result["is_stack"] is False + assert result["member_count"] == 0 + assert result["members"] == [] + assert result["detection_error"] is None + + def test_returns_new_dict_each_call(self): + """Each call returns an independent dict (not a shared reference).""" + from netbox_librenms_plugin.import_utils.virtual_chassis import empty_virtual_chassis_data + + a = empty_virtual_chassis_data() + b = empty_virtual_chassis_data() + a["members"].append("x") + assert b["members"] == [] + + +class TestCloneVirtualChassisDataAdditional: + """Additional _clone_virtual_chassis_data edge cases.""" + + def test_none_input_returns_empty(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + result = _clone_virtual_chassis_data(None) + assert result["is_stack"] is False + assert result["members"] == [] + + def test_empty_dict_returns_empty(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + result = _clone_virtual_chassis_data({}) + assert result["is_stack"] is False + assert result["members"] == [] + + def test_full_data_defensive_copy(self): + """Members list is a new list; mutating it does not affect the source.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 1, + "members": [{"serial": "SN1", "position": 1}], + "detection_error": None, + } + result = _clone_virtual_chassis_data(data) + result["members"].append({"serial": "SN-NEW", "position": 2}) + assert len(data["members"]) == 1 # original untouched + + def test_detection_error_preserved(self): + """detection_error field from source data is preserved.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 1, + "members": [], + "detection_error": "Some error", + } + result = _clone_virtual_chassis_data(data) + assert result["detection_error"] == "Some error" + + def test_member_with_zero_position_replaced_by_one_based(self): + """A member with position=0 is replaced by idx+1 (1-based).""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S0", "position": 0}, {"serial": "S2", "position": 2}], + } + result = _clone_virtual_chassis_data(data) + assert result["members"][0]["position"] == 1 # 0 β†’ idx+1 = 1 + assert result["members"][1]["position"] == 2 # kept as-is + + def test_member_count_falls_back_to_len_when_zero(self): + """member_count=0 in source is replaced by len(members).""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _clone_virtual_chassis_data + + data = { + "is_stack": True, + "member_count": 0, + "members": [{"serial": "S1", "position": 1}, {"serial": "S2", "position": 2}], + } + result = _clone_virtual_chassis_data(data) + assert result["member_count"] == 2 + + +class TestVCCacheKey: + """Tests for _vc_cache_key.""" + + def test_cache_key_format(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import _vc_cache_key + + mock_api = MagicMock() + mock_api.server_key = "default" + key = _vc_cache_key(mock_api, 42) + assert "librenms_vc_detection" in key + assert "default" in key + assert "42" in key + + def test_cache_key_includes_server_key(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import _vc_cache_key + + api_a = MagicMock() + api_a.server_key = "server-a" + api_b = MagicMock() + api_b.server_key = "server-b" + assert _vc_cache_key(api_a, 1) != _vc_cache_key(api_b, 1) + + def test_cache_key_differs_for_different_device_ids(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import _vc_cache_key + + mock_api = MagicMock() + mock_api.server_key = "default" + assert _vc_cache_key(mock_api, 1) != _vc_cache_key(mock_api, 2) + + def test_missing_server_key_falls_back_to_default(self): + """api without server_key attribute uses 'default' as fallback.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _vc_cache_key + + mock_api = MagicMock(spec=[]) # no attributes + key = _vc_cache_key(mock_api, 10) + assert "default" in key + + +class TestGetVirtualChassisData: + """Tests for get_virtual_chassis_data.""" + + def test_none_api_returns_empty(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + result = get_virtual_chassis_data(None, 1) + assert result["is_stack"] is False + assert result["members"] == [] + + def test_none_device_id_returns_empty(self): + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + mock_api = MagicMock() + result = get_virtual_chassis_data(mock_api, None) + assert result["is_stack"] is False + + def test_cache_hit_returns_cloned_data(self): + """Cached data is returned without calling detect_virtual_chassis_from_inventory.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + mock_api = MagicMock() + mock_api.server_key = "default" + cached = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S1", "position": 1}, {"serial": "S2", "position": 2}], + "detection_error": None, + } + + with ( + patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.detect_virtual_chassis_from_inventory" + ) as mock_detect, + ): + mock_cache.get.return_value = cached + result = get_virtual_chassis_data(mock_api, 42) + + assert result["is_stack"] is True + assert result["member_count"] == 2 + mock_detect.assert_not_called() + + def test_cache_miss_calls_detect_and_stores_result(self): + """On cache miss, detect_virtual_chassis_from_inventory is called and result cached.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + mock_api = MagicMock() + mock_api.server_key = "default" + mock_api.cache_timeout = 300 + + detection_result = {"is_stack": False, "member_count": 0, "members": []} + + with ( + patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.detect_virtual_chassis_from_inventory", + return_value=detection_result, + ) as mock_detect, + ): + mock_cache.get.return_value = None # cache miss + result = get_virtual_chassis_data(mock_api, 42) + + mock_detect.assert_called_once_with(mock_api, 42) + mock_cache.set.assert_called_once() + assert result["is_stack"] is False + + def test_cache_miss_detect_returns_none_stores_empty(self): + """When detect returns None, empty VC data is stored and returned.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + mock_api = MagicMock() + mock_api.server_key = "default" + mock_api.cache_timeout = 300 + + with ( + patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.detect_virtual_chassis_from_inventory", + return_value=None, + ), + ): + mock_cache.get.return_value = None + result = get_virtual_chassis_data(mock_api, 99) + + mock_cache.set.assert_called_once() + assert result["is_stack"] is False + + def test_force_refresh_bypasses_cache(self): + """force_refresh=True skips the cache.get check.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import get_virtual_chassis_data + + mock_api = MagicMock() + mock_api.server_key = "default" + mock_api.cache_timeout = 300 + + with ( + patch("netbox_librenms_plugin.import_utils.virtual_chassis.cache") as mock_cache, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.detect_virtual_chassis_from_inventory", + return_value=None, + ), + ): + mock_cache.get.return_value = {"is_stack": True, "member_count": 1, "members": [], "detection_error": None} + get_virtual_chassis_data(mock_api, 1, force_refresh=True) + + # cache.get should NOT have been consulted + mock_cache.get.assert_not_called() + + +class TestPrefetchVCData: + """Tests for prefetch_vc_data_for_devices.""" + + def test_none_api_returns_immediately(self): + """None api causes early return without touching get_virtual_chassis_data.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + with patch("netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data") as mock_get: + prefetch_vc_data_for_devices(None, [1, 2, 3]) + + mock_get.assert_not_called() + + def test_empty_device_ids_returns_immediately(self): + """Empty device_ids list causes early return.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + mock_api = MagicMock() + + with patch("netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data") as mock_get: + prefetch_vc_data_for_devices(mock_api, []) + + mock_get.assert_not_called() + + def test_connection_error_stops_processing(self): + """BrokenPipeError / ConnectionError stops the loop (return, not continue).""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + mock_api = MagicMock() + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data", + side_effect=ConnectionError("Connection reset"), + ) as mock_get: + prefetch_vc_data_for_devices(mock_api, [1, 2, 3]) + + # Only the first call fires before the connection error stops processing + assert mock_get.call_count == 1 + + def test_broken_pipe_error_stops_processing(self): + """BrokenPipeError is treated the same as ConnectionError.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + mock_api = MagicMock() + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data", + side_effect=BrokenPipeError("Pipe broken"), + ) as mock_get: + prefetch_vc_data_for_devices(mock_api, [10, 20]) + + assert mock_get.call_count == 1 + + def test_generic_exception_continues_to_next_device(self): + """Non-connection exceptions are logged but processing continues.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + mock_api = MagicMock() + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data", + side_effect=ValueError("Unexpected"), + ) as mock_get: + prefetch_vc_data_for_devices(mock_api, [1, 2, 3]) + + # All devices attempted despite the error + assert mock_get.call_count == 3 + + def test_success_calls_get_for_each_device(self): + """All device IDs are prefetched when no errors occur.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import prefetch_vc_data_for_devices + + mock_api = MagicMock() + + with patch("netbox_librenms_plugin.import_utils.virtual_chassis.get_virtual_chassis_data") as mock_get: + prefetch_vc_data_for_devices(mock_api, [10, 20, 30]) + + assert mock_get.call_count == 3 + + +class TestDetectVirtualChassisFromInventory: + """Tests for detect_virtual_chassis_from_inventory.""" + + def test_no_root_items_returns_none(self): + """Returns None when get_inventory_filtered returns no root items.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.return_value = (False, None) + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + def test_empty_root_items_returns_none(self): + """Returns None when root items list is empty.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.return_value = (True, []) + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + def test_no_stack_or_chassis_parent_returns_none(self): + """Returns None when no root item has class 'stack' or 'chassis'.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.return_value = ( + True, + [{"entPhysicalClass": "other", "entPhysicalIndex": 1}], + ) + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + def test_single_child_chassis_returns_none(self): + """Returns None when only one child chassis is found (not a stack).""" + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + (True, [{"entPhysicalClass": "chassis", "entPhysicalIndex": 200}]), + ] + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + def test_stack_detected_with_two_chassis(self): + """Returns stack dict when two or more chassis are found under the parent.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + ( + True, + [ + { + "entPhysicalClass": "chassis", + "entPhysicalIndex": 201, + "entPhysicalParentRelPos": 1, + "entPhysicalSerialNum": "SN1", + "entPhysicalModelName": "C9300-48P", + "entPhysicalName": "Switch 1", + "entPhysicalDescr": "Cisco Catalyst 9300", + }, + { + "entPhysicalClass": "chassis", + "entPhysicalIndex": 202, + "entPhysicalParentRelPos": 2, + "entPhysicalSerialNum": "SN2", + "entPhysicalModelName": "C9300-48P", + "entPhysicalName": "Switch 2", + "entPhysicalDescr": "Cisco Catalyst 9300", + }, + ], + ), + ] + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = detect_virtual_chassis_from_inventory(mock_api, 1) + + assert result is not None + assert result["is_stack"] is True + assert result["member_count"] == 2 + assert len(result["members"]) == 2 + assert result["members"][0]["serial"] == "SN1" + assert result["members"][1]["serial"] == "SN2" + + def test_stack_members_sorted_by_position(self): + """Members are sorted by position ascending.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + ( + True, + [ + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 3, "entPhysicalIndex": 203}, + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 1, "entPhysicalIndex": 201}, + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 2, "entPhysicalIndex": 202}, + ], + ), + ] + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = detect_virtual_chassis_from_inventory(mock_api, 1) + + positions = [m["position"] for m in result["members"]] + assert positions == [1, 2, 3] + + def test_zero_position_replaced_by_one_based_index(self): + """entPhysicalParentRelPos=0 is replaced by idx+1.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + ( + True, + [ + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 0, "entPhysicalIndex": 201}, + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 2, "entPhysicalIndex": 202}, + ], + ), + ] + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = detect_virtual_chassis_from_inventory(mock_api, 1) + + positions = [m["position"] for m in result["members"]] + assert 0 not in positions + assert 1 in positions + + def test_no_master_name_uses_member_prefix(self): + """When device_info has no sysName/hostname, suggested_name uses 'Member-N'.""" + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (False, None) # no master name + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + ( + True, + [ + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 1, "entPhysicalIndex": 201}, + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": 2, "entPhysicalIndex": 202}, + ], + ), + ] + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + + assert result is not None + assert result["members"][0]["suggested_name"].startswith("Member-") + + def test_child_items_fetch_fails_returns_none(self): + """Returns None when the second get_inventory_filtered call fails.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + (False, None), # child fetch fails + ] + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + def test_exception_returns_none(self): + """Unhandled exception inside the function returns None.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.side_effect = RuntimeError("Unexpected") + + result = detect_virtual_chassis_from_inventory(mock_api, 1) + assert result is None + + +class TestLoadVCMemberNamePattern: + """Tests for _load_vc_member_name_pattern.""" + + def test_returns_pattern_from_settings(self): + """Returns vc_member_name_pattern from LibreNMSSettings when found.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import _load_vc_member_name_pattern + + mock_settings = MagicMock() + mock_settings.vc_member_name_pattern = "-SW{position}" + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_cls: + mock_cls.objects.order_by.return_value.first.return_value = mock_settings + result = _load_vc_member_name_pattern() + + assert result == "-SW{position}" + + def test_no_settings_returns_default(self): + """Returns '-M{position}' when LibreNMSSettings.objects.order_by().first() returns None.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import _load_vc_member_name_pattern + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_cls: + mock_cls.objects.order_by.return_value.first.return_value = None + result = _load_vc_member_name_pattern() + + assert result == "-M{position}" + + def test_exception_returns_default(self): + """Returns '-M{position}' when the DB query raises an exception.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import _load_vc_member_name_pattern + + with patch("netbox_librenms_plugin.models.LibreNMSSettings") as mock_cls: + mock_cls.objects.order_by.side_effect = Exception("DB offline") + result = _load_vc_member_name_pattern() + + assert result == "-M{position}" + + +class TestGenerateVCMemberNameAdditional: + """Additional tests for _generate_vc_member_name.""" + + def test_with_serial_in_pattern(self): + """Pattern using {serial} placeholder substitutes the serial number.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + name = _generate_vc_member_name("switch-1", 2, serial="ABC123", pattern=" [{serial}]") + assert name == "switch-1 [ABC123]" + + def test_empty_serial_produces_empty_brackets(self): + """Empty serial with {serial} pattern results in empty brackets.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + name = _generate_vc_member_name("switch-1", 1, serial="", pattern=" [{serial}]") + assert name == "switch-1 []" + + def test_invalid_placeholder_falls_back_to_default(self): + """A KeyError from an unknown placeholder triggers the '-M{position}' fallback.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + name = _generate_vc_member_name("switch-1", 3, pattern="-{nonexistent_key}") + assert name == "switch-1-M3" + + def test_none_pattern_loads_from_settings(self): + """When pattern=None, _load_vc_member_name_pattern is called to fetch the pattern.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ) as mock_load: + name = _generate_vc_member_name("router", 5, pattern=None) + + mock_load.assert_called_once() + assert name == "router-M5" + + def test_master_name_placeholder(self): + """Pattern can also reference {master_name}.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name + + name = _generate_vc_member_name("sw", 2, pattern="-{master_name}-pos{position}") + assert name == "sw-sw-pos2" + + +class TestUpdateVCMemberSuggestedNamesAdditional: + """Additional tests for update_vc_member_suggested_names.""" + + def test_not_stack_returns_vc_data_unchanged(self): + """When is_stack=False, the function returns immediately without modifying members.""" + from netbox_librenms_plugin.import_utils.virtual_chassis import update_vc_member_suggested_names + + vc_data = { + "is_stack": False, + "members": [{"serial": "S1", "position": 1, "suggested_name": "old-name"}], + } + result = update_vc_member_suggested_names(vc_data, "sw") + # suggested_name must not be regenerated + assert result["members"][0]["suggested_name"] == "old-name" + + def test_none_vc_data_returns_none(self): + """None input is returned as-is (falsy guard).""" + from netbox_librenms_plugin.import_utils.virtual_chassis import update_vc_member_suggested_names + + result = update_vc_member_suggested_names(None, "sw") + assert result is None + + def test_no_members_returns_empty_members(self): + """is_stack=True with empty members list processes without error.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import update_vc_member_suggested_names + + vc_data = {"is_stack": True, "members": []} + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = update_vc_member_suggested_names(vc_data, "sw") + + assert result["members"] == [] + + +class TestCreateVirtualChassisWithMembers: + """Tests for create_virtual_chassis_with_members.""" + + def test_raises_when_vc_create_fails(self): + """Exception from VirtualChassis.objects.create is re-raised to the caller.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + + @contextmanager + def mock_atomic(): + yield + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M1", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False + mock_vc_cls.objects.create.side_effect = Exception("DB error") + + import pytest + + with pytest.raises(Exception, match="DB error"): + create_virtual_chassis_with_members(master_device, [], {"device_id": 1}) + + def test_success_with_no_members(self): + """Happy path with empty members_info creates VC and returns it.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M1", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False + mock_vc_cls.objects.create.return_value = mock_vc + + result = create_virtual_chassis_with_members(master_device, [], {"device_id": 1}) + + assert result == mock_vc + mock_vc_cls.objects.create.assert_called_once() + + +class TestBulkImportCancellation: + """Test that bulk_import_devices_shared respects RQ and DB cancellation.""" + + def _run_bulk_import(self, mock_rq_job=None, db_status="running", device_ids=None): + """Helper: run bulk_import with provided mocks, return import call count.""" + from unittest.mock import MagicMock, patch + + if device_ids is None: + device_ids = [1, 2, 3, 4, 5, 6] + + job = MagicMock() + job.job.job_id = "test-uuid" + job_status = MagicMock() + job_status.value = db_status + job.job.status = job_status + job.logger = MagicMock() + + with ( + patch("netbox_librenms_plugin.import_utils.bulk_import.LibreNMSAPI") as mock_api_cls, + 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"), + patch("netbox_librenms_plugin.import_utils.bulk_import.require_permissions"), + # Inline imports in the loop use django_rq.get_queue / rq.job.Job directly + patch("django_rq.get_queue") as mock_get_queue, + patch("rq.job.Job") as mock_rqjob_cls, + ): + mock_api = MagicMock() + mock_api.server_key = "default" + mock_api.get_device_info.return_value = (True, {"device_id": 1, "hostname": "sw"}) + mock_api_cls.return_value = mock_api + mock_import.return_value = {"success": True, "device": MagicMock(), "is_vm": False} + + if mock_rq_job is not None: + mock_conn = MagicMock() + mock_queue = MagicMock() + mock_queue.connection = mock_conn + mock_get_queue.return_value = mock_queue + mock_rqjob_cls.fetch.return_value = mock_rq_job + else: + # Simulate RQ unavailable β€” get_queue raises, triggers DB fallback + mock_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, user=MagicMock(), server_key=None, job=job) + + return mock_import.call_count + + def test_rq_stopped_cancels_import_loop(self): + """When RQ job is_stopped, import loop should break early.""" + rq_job = MagicMock() + rq_job.is_stopped = True + rq_job.is_failed = False + rq_job.get_status.return_value = "stopped" + + # With 6 devices and RQ stopped on first check (idx=1), at most 1 device processed + count = self._run_bulk_import(mock_rq_job=rq_job, device_ids=[1, 2, 3, 4, 5, 6]) + assert count == 0 # break before first import + + def test_rq_failed_cancels_import_loop(self): + """When RQ job is_failed, import loop should break early.""" + rq_job = MagicMock() + rq_job.is_stopped = False + rq_job.is_failed = True + rq_job.get_status.return_value = "failed" + + count = self._run_bulk_import(mock_rq_job=rq_job, device_ids=[1, 2, 3, 4, 5, 6]) + assert count == 0 + + def test_rq_unavailable_falls_back_to_db_check(self): + """When RQ is unavailable, DB status check is used as fallback.""" + # mock_rq_job=None triggers the side_effect=Exception path + count = self._run_bulk_import(mock_rq_job=None, db_status="failed", device_ids=[1]) + # With DB status "failed", import should not run + assert count == 0 + + def test_db_errored_status_also_terminates_loop(self): + """When DB job status is 'errored', import loop should terminate early.""" + count = self._run_bulk_import(mock_rq_job=None, db_status="errored", device_ids=[1, 2, 3]) + assert count == 0 + + def test_healthy_job_runs_all_devices(self): + """When job is healthy, all devices should be imported.""" + rq_job = MagicMock() + rq_job.is_stopped = False + rq_job.is_failed = False + rq_job.get_status.return_value = "started" + + count = self._run_bulk_import(mock_rq_job=rq_job, device_ids=[1, 2, 3]) + assert count == 3 + + +# --------------------------------------------------------------------------- +# Tests for DeviceValidationDetailsView._build_id_server_info +# --------------------------------------------------------------------------- + + +class TestBuildIdServerInfo: + """Test DeviceValidationDetailsView._build_id_server_info method.""" + + def _make_device(self, librenms_id_value): + from unittest.mock import MagicMock + + device = MagicMock() + device.custom_field_data = {"librenms_id": librenms_id_value} + return device + + def test_returns_none_for_legacy_int(self): + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + device = self._make_device(42) + result = DeviceValidationDetailsView._build_id_server_info(device) + assert result is None + + def test_returns_none_for_missing_cf(self): + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + device = self._make_device(None) + result = DeviceValidationDetailsView._build_id_server_info(device) + assert result is None + + def test_single_server_resolves_display_name(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + device = self._make_device({"production": 42}) + plugins_cfg = { + "netbox_librenms_plugin": { + "servers": { + "production": {"display_name": "Production LibreNMS", "librenms_url": "https://prod.example.com"}, + } + } + } + with patch("django.conf.settings") as mock_settings: + mock_settings.PLUGINS_CONFIG = plugins_cfg + result = DeviceValidationDetailsView._build_id_server_info(device) + + assert result is not None + assert len(result) == 1 + assert result[0]["server_key"] == "production" + assert result[0]["display_name"] == "Production LibreNMS" + assert result[0]["device_id"] == 42 + + def test_unconfigured_server_uses_key_as_display_name(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + device = self._make_device({"deleted-server": 77}) + plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} + with patch("django.conf.settings") as mock_settings: + mock_settings.PLUGINS_CONFIG = plugins_cfg + result = DeviceValidationDetailsView._build_id_server_info(device) + + assert result is not None + assert result[0]["display_name"] == "deleted-server" + + def test_empty_dict_returns_none(self): + from netbox_librenms_plugin.views.imports.actions import DeviceValidationDetailsView + + device = self._make_device({}) + result = DeviceValidationDetailsView._build_id_server_info(device) + assert result is None + + +# --------------------------------------------------------------------------- +# Tests for _refresh_existing_device sys_name fallback fix +# --------------------------------------------------------------------------- + + +class TestRefreshExistingDeviceSysNameFallback: + """Test that _refresh_existing_device tries sys_name even when hostname is empty.""" + + def test_sysname_used_when_hostname_empty(self): + """When hostname is empty but sys_name matches, the device is found in validation.""" + from unittest.mock import MagicMock, patch + + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + mock_device = MagicMock() + mock_device.pk = 99 + mock_device.name = "router-01" + mock_device.custom_field_data = {"librenms_id": None} + + libre_device = { + "device_id": 55, + "hostname": "", # empty hostname + "sysName": "router-01", + "serial": "SN-MATCH", + } + validation = { + "existing_device": None, + "existing_vm": None, + "import_as_vm": False, + "is_ready": False, + "can_import": False, + } + + # sys_name lookup: filter(name__iexact="router-01") returns mock_device + # hostname lookup: filter(name__iexact="") returns None + def make_qs(return_val): + qs = MagicMock() + qs.first.return_value = return_val + return qs + + with patch("netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", return_value=None): + import dcim.models as dcim_models + import virtualization.models as virt_models + + with ( + patch.object( + dcim_models.Device.objects, + "filter", + side_effect=lambda **kw: make_qs(mock_device if kw.get("name__iexact") == "router-01" else None), + ), + patch.object(virt_models.VirtualMachine.objects, "filter", return_value=make_qs(None)), + ): + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + assert validation["existing_device"] is mock_device + + def test_hostname_lookup_succeeds_without_sysname(self): + """When hostname is non-empty and matches, validation is updated correctly.""" + from unittest.mock import MagicMock, patch + + from netbox_librenms_plugin.import_utils.bulk_import import _refresh_existing_device + + mock_device = MagicMock() + mock_device.pk = 10 + mock_device.name = "sw-01" + mock_device.custom_field_data = {"librenms_id": None} + + libre_device = { + "device_id": 10, + "hostname": "sw-01", + "sysName": "sw-01-sysname", + "serial": "", + } + validation = { + "existing_device": None, + "existing_vm": None, + "import_as_vm": False, + "is_ready": False, + "can_import": False, + } + + def make_qs(return_val): + qs = MagicMock() + qs.first.return_value = return_val + return qs + + with patch("netbox_librenms_plugin.import_utils.bulk_import.find_by_librenms_id", return_value=None): + import dcim.models as dcim_models + import virtualization.models as virt_models + + with ( + patch.object( + dcim_models.Device.objects, + "filter", + side_effect=lambda **kw: make_qs(mock_device if kw.get("name__iexact") == "sw-01" else None), + ), + patch.object(virt_models.VirtualMachine.objects, "filter", return_value=make_qs(None)), + ): + _refresh_existing_device(validation, libre_device=libre_device, server_key="default") + + assert validation["existing_device"] is mock_device + + +# --------------------------------------------------------------------------- +# Tests for _get_hostname_for_action helper +# --------------------------------------------------------------------------- + + +class TestGetHostnameForAction: + """Test _get_hostname_for_action helper in actions.py.""" + + def test_returns_resolved_name_when_set(self): + from unittest.mock import MagicMock + + from netbox_librenms_plugin.views.imports.actions import _get_hostname_for_action + + request = MagicMock() + validation = {"resolved_name": "cached-name"} + libre_device = {"hostname": "raw-hostname", "sysName": "raw-sysname"} + + result = _get_hostname_for_action(request, validation, libre_device) + assert result == "cached-name" + + def test_falls_back_to_determine_device_name(self): + from unittest.mock import MagicMock, patch + + from netbox_librenms_plugin.views.imports.actions import _get_hostname_for_action + + request = MagicMock() + validation = {} # no resolved_name + libre_device = {"hostname": "host.example.com", "sysName": "host"} + + with patch("netbox_librenms_plugin.views.imports.actions._resolve_naming_preferences") as mock_prefs: + mock_prefs.return_value = (False, False) # use_sysname=False, strip_domain=False + with patch("netbox_librenms_plugin.views.imports.actions._determine_device_name") as mock_name: + mock_name.return_value = "host.example.com" + result = _get_hostname_for_action(request, validation, libre_device) + + assert result == "host.example.com" + mock_prefs.assert_called_once_with(request) + mock_name.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests for _resolve_naming_preferences underscore-variant key support +# --------------------------------------------------------------------------- + + +class TestResolveNamingPreferencesKeys: + """Test that _resolve_naming_preferences handles both hyphenated and underscored keys.""" + + def _make_request(self, post=None, get=None): + from unittest.mock import MagicMock + + request = MagicMock() + request.POST = post or {} + request.GET = get or {} + request.user = MagicMock() + return request + + def test_hyphenated_post_key_use_sysname(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + request = self._make_request(post={"use-sysname-toggle": "on", "strip-domain-toggle": "off"}) + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=None): + use_sysname, strip_domain = _resolve_naming_preferences(request) + assert use_sysname is True + assert strip_domain is False + + def test_underscored_post_key_use_sysname(self): + """Underscore variant 'use_sysname-toggle' should also be recognised.""" + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + request = self._make_request(post={"use_sysname-toggle": "on", "strip_domain-toggle": "on"}) + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=None): + use_sysname, strip_domain = _resolve_naming_preferences(request) + assert use_sysname is True + assert strip_domain is True + + def test_get_key_used_when_not_in_post(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + request = self._make_request(get={"use-sysname-toggle": "off", "strip-domain-toggle": "on"}) + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=None): + use_sysname, strip_domain = _resolve_naming_preferences(request) + assert use_sysname is False + assert strip_domain is True + + def test_user_pref_used_when_no_toggle_in_request(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + request = self._make_request() + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref") as mock_pref: + mock_pref.side_effect = lambda req, key: False if "use_sysname" in key else True + use_sysname, strip_domain = _resolve_naming_preferences(request) + assert use_sysname is False + assert strip_domain is True + + def test_post_takes_precedence_over_user_pref(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + request = self._make_request(post={"use-sysname-toggle": "off"}) + # user_pref would say True β€” POST should win + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=True): + use_sysname, _ = _resolve_naming_preferences(request) + assert use_sysname is False + + def test_truthy_string_true_value(self): + """'true' and '1' (in addition to 'on') should be treated as True.""" + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + for truthy_val in ("true", "True", "TRUE", "1"): + request = self._make_request(post={"use-sysname-toggle": truthy_val, "strip-domain-toggle": "off"}) + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=None): + use_sysname, _ = _resolve_naming_preferences(request) + assert use_sysname is True, f"Expected True for value {truthy_val!r}" + + def test_falsy_string_false_value(self): + """Unrecognised strings should be treated as False.""" + from unittest.mock import patch + + from netbox_librenms_plugin.views.imports.actions import _resolve_naming_preferences + + for falsy_val in ("off", "false", "0", "", "no"): + request = self._make_request(post={"use-sysname-toggle": falsy_val, "strip-domain-toggle": "off"}) + with patch("netbox_librenms_plugin.views.imports.actions.get_user_pref", return_value=None): + use_sysname, _ = _resolve_naming_preferences(request) + assert use_sysname is False, f"Expected False for value {falsy_val!r}" + + +# --------------------------------------------------------------------------- +# Tests for vc_domain stack dedup key fix +# --------------------------------------------------------------------------- + + +class TestVCDomainStackDedup: + """Test that bulk_import_devices_shared deduplicates VC creation by member serials.""" + + def test_vc_domain_uses_member_serials(self): + """vc_domain for two stack members with the same serials should be identical.""" + # The logic lives inline; test the produced key directly from vc_data + members = [ + {"serial": "SN100", "position": 1}, + {"serial": "SN200", "position": 2}, + ] + member_serials = sorted(m.get("serial") for m in members if m.get("serial")) + vc_domain = f"librenms-stack-{','.join(member_serials)}" + + # Same members from a different device's perspective should produce the same key + assert vc_domain == "librenms-stack-SN100,SN200" + + def test_vc_domain_fallback_to_device_id_when_no_serials(self): + """When no member serials are available, device_id is used as fallback.""" + members = [ + {"position": 1}, + {"position": 2}, + ] + member_serials = sorted(m.get("serial") for m in members if m.get("serial")) + device_id = 42 + vc_domain = f"librenms-stack-{','.join(member_serials)}" if member_serials else f"librenms-{device_id}" + assert vc_domain == "librenms-42" + + def test_different_stacks_produce_different_keys(self): + """Two stacks with different serials produce distinct dedup keys.""" + members_a = [{"serial": "SN-A1"}, {"serial": "SN-A2"}] + members_b = [{"serial": "SN-B1"}, {"serial": "SN-B2"}] + key_a = f"librenms-stack-{','.join(sorted(m['serial'] for m in members_a))}" + key_b = f"librenms-stack-{','.join(sorted(m['serial'] for m in members_b))}" + assert key_a != key_b + + +class TestVirtualChassisEdgeBranches: + """Targeted tests for exception branches not covered by main tests.""" + + def test_detect_vc_invalid_position_string_falls_back(self): + """When entPhysicalParentRelPos is a non-numeric string, position falls back to idx+1.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import detect_virtual_chassis_from_inventory + + mock_api = MagicMock() + mock_api.get_device_info.return_value = (True, {"sysName": "sw1"}) + mock_api.get_inventory_filtered.side_effect = [ + (True, [{"entPhysicalClass": "stack", "entPhysicalIndex": 100}]), + ( + True, + [ + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": "bad", "entPhysicalIndex": 201}, + {"entPhysicalClass": "chassis", "entPhysicalParentRelPos": "invalid", "entPhysicalIndex": 202}, + ], + ), + ] + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = detect_virtual_chassis_from_inventory(mock_api, 1) + + # invalid string β†’ idx+1 fallback (1-based: idx=0β†’1, idx=1β†’2) + positions = sorted(m["position"] for m in result["members"]) + assert positions == [1, 2] + + def test_update_vc_suggested_names_invalid_position_string_falls_back(self): + """Non-numeric position string in member triggers except branch β†’ idx+1 fallback.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import update_vc_member_suggested_names + + vc_data = { + "is_stack": True, + "member_count": 2, + "members": [{"serial": "S1", "position": "bad"}, {"serial": "S2", "position": None}], + } + + with patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ): + result = update_vc_member_suggested_names(vc_data, "sw") + + positions = [m["position"] for m in result["members"]] + assert positions[0] == 1 # idx=0 β†’ 1 + assert positions[1] == 2 # idx=1 β†’ 2 + + def _make_atomic(self): + from contextlib import contextmanager + + @contextmanager + def _atomic(): + yield + + return _atomic + + def _base_patches(self): + from unittest.mock import patch + + return [ + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + self._make_atomic(), + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ] + + def test_create_vc_master_name_conflict_keeps_original(self): + """When the renamed master clashes, master_base_name stays as original.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M1", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + # Name conflict: renamed master already exists + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = True + mock_vc_cls.objects.create.return_value = mock_vc + + result = create_virtual_chassis_with_members(master_device, [], {"device_id": 1}) + + # VC still created; master.name was NOT changed (conflict) + assert result == mock_vc + assert master_device.name == "sw1" + + def test_create_vc_member_serial_matches_master_skipped(self): + """Member whose serial equals master serial is skipped.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "SERIAL-MASTER" + master_device.rack = None + master_device.location = None + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M1", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False + mock_device_cls.objects.filter.return_value.exists.return_value = False + mock_vc_cls.objects.create.return_value = mock_vc + + # One member with same serial as master β†’ should be skipped + members_info = [{"serial": "SERIAL-MASTER", "position": 2, "name": "sw1-2"}] + create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + # Device.objects.create should NOT be called (member skipped) + mock_device_cls.objects.create.assert_not_called() + + def test_create_vc_member_duplicate_serial_skipped(self): + """Member with a serial that already exists in DB is skipped.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + def _filter_exists(*args, **kwargs): + # First call: check renamed master name conflict (exclude().exists()) β†’ False + # Subsequent calls: check duplicate serial β†’ True (for serial) + mock = MagicMock() + mock.exclude.return_value.exists.return_value = False + mock.exists.return_value = True # serial already exists + return mock + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M2", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.side_effect = _filter_exists + mock_vc_cls.objects.create.return_value = mock_vc + + members_info = [{"serial": "DUP-SERIAL", "position": 2, "name": "sw1-2"}] + create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + mock_device_cls.objects.create.assert_not_called() + + def test_create_vc_member_created_successfully(self): + """Normal member (no duplicate serial/name) is created via Device.objects.create.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + master_device.platform = None + master_device.role = MagicMock() + master_device.device_type = MagicMock() + master_device.site = MagicMock() + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 2 + + @contextmanager + def mock_atomic(): + yield + + def _filter_side_effect(*args, **kwargs): + mock = MagicMock() + mock.exclude.return_value.exists.return_value = False # no name conflict + mock.exists.return_value = False # no duplicate serial or name + return mock + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M2", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.side_effect = _filter_side_effect + mock_vc_cls.objects.create.return_value = mock_vc + + members_info = [{"serial": "NEW-SERIAL", "position": 2, "name": "sw1-2"}] + result = create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + mock_device_cls.objects.create.assert_called_once() + assert result == mock_vc + + def test_create_vc_member_count_warning_when_fewer_created(self): + """Warning is logged when members_created < expected_members.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + def _filter_side_effect(*args, **kwargs): + mock = MagicMock() + mock.exclude.return_value.exists.return_value = False + # serial check: True β†’ member skipped + mock.exists.return_value = True + return mock + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M2", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.logger") as mock_logger, + ): + mock_device_cls.objects.filter.side_effect = _filter_side_effect + mock_vc_cls.objects.create.return_value = mock_vc + + # 2 members expected, both skipped β†’ warning + members_info = [ + {"serial": "S1", "position": 2, "name": "sw1-2"}, + {"serial": "S2", "position": 3, "name": "sw1-3"}, + ] + create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + # Warning should be called for count mismatch + mock_logger.warning.assert_called() + + def test_create_vc_member_zero_position_and_name_conflict(self): + """Member position=0 β†’ discovered_pos=None, and name conflict β†’ skip.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + master_device.platform = None + master_device.role = MagicMock() + master_device.device_type = MagicMock() + master_device.site = MagicMock() + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 1 + + @contextmanager + def mock_atomic(): + yield + + filter_call_count = [0] + + def _filter_side_effect(*args, **kwargs): + mock = MagicMock() + mock.exclude.return_value.exists.return_value = False # no renamed-master conflict + filter_call_count[0] += 1 + # call 1: renamed-master name conflict check (.exclude().exists()) β†’ handled above + # call 2: serial duplicate check (.exists()) β†’ False (serial doesn't exist) + # call 3: member name conflict check (.exists()) β†’ True (name already taken) + mock.exists.return_value = filter_call_count[0] == 3 + return mock + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M2", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.side_effect = _filter_side_effect + mock_vc_cls.objects.create.return_value = mock_vc + + # position=0 β†’ discovered_pos normalized to None; serial present but name conflicts + members_info = [{"serial": "S-UNIQUE", "position": 0, "name": "sw1-2"}] + create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + # Member skipped due to name conflict (not created) + mock_device_cls.objects.create.assert_not_called() + + def test_create_vc_member_invalid_position_string_uses_sequential(self): + """Member with position='abc' (non-int) triggers except branch β†’ uses sequential counter.""" + from contextlib import contextmanager + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.virtual_chassis import create_virtual_chassis_with_members + + master_device = MagicMock() + master_device.name = "sw1" + master_device.pk = 1 + master_device.serial = "" + master_device.rack = None + master_device.location = None + master_device.platform = None + master_device.role = MagicMock() + master_device.device_type = MagicMock() + master_device.site = MagicMock() + + mock_vc = MagicMock() + mock_vc.members.count.return_value = 2 + + created_positions = [] + + @contextmanager + def mock_atomic(): + yield + + def _filter_side_effect(*args, **kwargs): + mock = MagicMock() + mock.exclude.return_value.exists.return_value = False + mock.exists.return_value = False + return mock + + def _capture_create(**kwargs): + created_positions.append(kwargs.get("vc_position")) + return MagicMock() + + with ( + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis.transaction.atomic", + mock_atomic, + ), + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._generate_vc_member_name", + return_value="sw1-M2", + ), + patch("netbox_librenms_plugin.import_utils.virtual_chassis.Device") as mock_device_cls, + patch("netbox_librenms_plugin.import_utils.virtual_chassis.VirtualChassis") as mock_vc_cls, + patch( + "netbox_librenms_plugin.import_utils.virtual_chassis._load_vc_member_name_pattern", + return_value="-M{position}", + ), + ): + mock_device_cls.objects.filter.side_effect = _filter_side_effect + mock_device_cls.objects.create.side_effect = _capture_create + mock_vc_cls.objects.create.return_value = mock_vc + + # "abc" position β†’ except branch β†’ sequential fallback (position=2, then +=1) + members_info = [{"serial": "S1", "position": "abc", "name": "m1"}] + create_virtual_chassis_with_members(master_device, members_info, {"device_id": 1}) + + assert mock_device_cls.objects.create.call_count == 1 diff --git a/netbox_librenms_plugin/tests/test_init.py b/netbox_librenms_plugin/tests/test_init.py new file mode 100644 index 0000000000..426f6736c0 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_init.py @@ -0,0 +1,174 @@ +"""Tests for netbox_librenms_plugin.__init__ module. + +Covers the _ensure_librenms_id_custom_field post_migrate signal handler. +""" + +from unittest.mock import MagicMock, patch + + +# ============================================================================= +# TestEnsureLibreNMSIdCustomField - 6 tests +# ============================================================================= + + +class TestEnsureLibreNMSIdCustomField: + """Test _ensure_librenms_id_custom_field signal handler.""" + + def setup_method(self): + """Reset the _executed flag before each test for consistent isolation.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + _ensure_librenms_id_custom_field._executed = False + + @patch("dcim.models.Interface", new_callable=MagicMock) + @patch("dcim.models.Device", new_callable=MagicMock) + @patch("virtualization.models.VMInterface", new_callable=MagicMock) + @patch("virtualization.models.VirtualMachine", new_callable=MagicMock) + @patch("django.contrib.contenttypes.models.ContentType") + @patch("extras.models.CustomField") + def test_creates_custom_field_when_missing( + self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface + ): + """Custom field is created with correct defaults when it does not exist.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + mock_cf = MagicMock() + mock_cf.object_types.values_list.return_value = [] + MockCustomField.objects.get_or_create.return_value = (mock_cf, True) + + mock_ct = MagicMock() + mock_ct.pk = 1 + MockContentType.objects.get_for_model.return_value = mock_ct + + with patch("logging.getLogger") as mock_get_logger: + _ensure_librenms_id_custom_field(sender=None) + + MockCustomField.objects.get_or_create.assert_called_once_with( + name="librenms_id", + defaults={ + "type": "json", + "label": "LibreNMS ID", + "description": "LibreNMS Device ID for synchronization (auto-created by plugin)", + "required": False, + "ui_visible": "if-set", + "ui_editable": "yes", + "is_cloneable": False, + }, + ) + + # Should have added content types for all 4 models + assert mock_cf.object_types.add.call_count == 4 + + # Should log when created + mock_get_logger.assert_called_with("netbox_librenms_plugin") + mock_get_logger.return_value.info.assert_called_once() + + def test_skips_when_already_executed(self): + """Handler is a no-op on second invocation (per-migrate dedup).""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + _ensure_librenms_id_custom_field._executed = True + + with patch("extras.models.CustomField") as MockCustomField: + _ensure_librenms_id_custom_field(sender=None) + MockCustomField.objects.get_or_create.assert_not_called() + + @patch("dcim.models.Interface", new_callable=MagicMock) + @patch("dcim.models.Device", new_callable=MagicMock) + @patch("virtualization.models.VMInterface", new_callable=MagicMock) + @patch("virtualization.models.VirtualMachine", new_callable=MagicMock) + @patch("django.contrib.contenttypes.models.ContentType") + @patch("extras.models.CustomField") + def test_existing_field_not_recreated( + self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface + ): + """When custom field already exists, it is not recreated but types are checked.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + mock_cf = MagicMock() + mock_cf.object_types.values_list.return_value = [1, 2, 3, 4] + MockCustomField.objects.get_or_create.return_value = (mock_cf, False) + + mock_ct = MagicMock() + mock_ct.pk = 1 + MockContentType.objects.get_for_model.return_value = mock_ct + + _ensure_librenms_id_custom_field(sender=None) + + # All pks already present, no types should be added + mock_cf.object_types.add.assert_not_called() + + @patch("dcim.models.Interface", new_callable=MagicMock) + @patch("dcim.models.Device", new_callable=MagicMock) + @patch("virtualization.models.VMInterface", new_callable=MagicMock) + @patch("virtualization.models.VirtualMachine", new_callable=MagicMock) + @patch("django.contrib.contenttypes.models.ContentType") + @patch("extras.models.CustomField") + def test_adds_missing_content_types( + self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface + ): + """When some content types are missing, only those are added.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + mock_cf = MagicMock() + mock_cf.object_types.values_list.return_value = [1, 2] + MockCustomField.objects.get_or_create.return_value = (mock_cf, False) + + ct_existing = MagicMock() + ct_existing.pk = 1 + ct_new = MagicMock() + ct_new.pk = 99 + MockContentType.objects.get_for_model.side_effect = [ct_existing, ct_existing, ct_new, ct_new] + + _ensure_librenms_id_custom_field(sender=None) + + assert mock_cf.object_types.add.call_count == 2 + mock_cf.object_types.add.assert_any_call(ct_new) + + @patch("extras.models.CustomField") + def test_exception_does_not_propagate(self, MockCustomField): + """Exceptions during custom field creation are caught and logged.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + MockCustomField.objects.get_or_create.side_effect = Exception("DB not ready") + + with patch("logging.getLogger") as mock_get_logger: + # Should not raise + _ensure_librenms_id_custom_field(sender=None) + + # Verify the exception was logged + logger_instance = mock_get_logger.return_value + logger_instance.exception.assert_called_once() + call_args = logger_instance.exception.call_args + assert "librenms_id" in call_args[0][0] + + # On failure, _executed must NOT be set β€” failed attempts should allow retry + assert not getattr(_ensure_librenms_id_custom_field, "_executed", False) + + @patch("dcim.models.Interface", new_callable=MagicMock) + @patch("dcim.models.Device", new_callable=MagicMock) + @patch("virtualization.models.VMInterface", new_callable=MagicMock) + @patch("virtualization.models.VirtualMachine", new_callable=MagicMock) + @patch("django.contrib.contenttypes.models.ContentType") + @patch("extras.models.CustomField") + def test_no_log_when_field_already_exists( + self, MockCustomField, MockContentType, mock_vm, mock_vmif, mock_device, mock_iface + ): + """No log message when the custom field already existed.""" + from netbox_librenms_plugin import _ensure_librenms_id_custom_field + + mock_cf = MagicMock() + mock_cf.object_types.values_list.return_value = [1, 2, 3, 4] + MockCustomField.objects.get_or_create.return_value = (mock_cf, False) + + mock_ct = MagicMock() + mock_ct.pk = 1 + MockContentType.objects.get_for_model.return_value = mock_ct + + with patch("logging.getLogger") as mock_get_logger: + _ensure_librenms_id_custom_field(sender=None) + # When the field already exists (created=False), the info log should + # not be emitted. We verify via the logger instance rather than + # asserting getLogger was never called, which is fragile. + logger_instance = mock_get_logger.return_value + logger_instance.info.assert_not_called() diff --git a/netbox_librenms_plugin/tests/test_integration_sync.py b/netbox_librenms_plugin/tests/test_integration_sync.py new file mode 100644 index 0000000000..d281b513c7 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_integration_sync.py @@ -0,0 +1,224 @@ +"""Integration tests using the mock LibreNMS HTTP server. + +These tests verify that LibreNMSAPI correctly parses responses from a real +(but local, mocked) HTTP server, and that the full request/response cycle works. +No Django database access is used; NetBox model interactions are mocked. +""" + +import json +import pytest + +from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server + + +@pytest.fixture +def mock_server(): + with librenms_mock_server() as server: + yield server + + +def _make_api(url, token="test-token"): + """Create a LibreNMSAPI instance pointed at the mock server.""" + from unittest.mock import patch + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + servers_config = { + "test": { + "librenms_url": url, + "api_token": token, + "cache_timeout": 0, + "verify_ssl": False, + } + } + + with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_cfg: + mock_cfg.side_effect = lambda _plugin, key: servers_config if key == "servers" else None + api = LibreNMSAPI(server_key="test") + return api + + +class TestMockServerSanity: + """The mock server itself must start, serve, and stop cleanly.""" + + def test_server_starts_and_responds(self, mock_server): + import urllib.request + + mock_server.register("/api/v0/test", {"status": "ok"}) + with urllib.request.urlopen(f"{mock_server.url}/api/v0/test") as resp: + data = json.loads(resp.read()) + assert data["status"] == "ok" + + def test_404_for_unregistered_path(self, mock_server): + import urllib.request + from urllib.error import HTTPError + + try: + urllib.request.urlopen(f"{mock_server.url}/api/v0/nonexistent") + except HTTPError as e: + assert e.code == 404 + else: + pytest.fail("Expected 404 HTTPError") + + +class TestLibreNMSAPIPortsFetch: + """LibreNMSAPI.get_ports() correctly parses mock server responses.""" + + def test_get_ports_returns_dict_with_ports_key(self, mock_server): + mock_server.ports_response(device_id=1) + api = _make_api(mock_server.url) + + success, data = api.get_ports(1) + + assert success is True + assert isinstance(data, dict) + assert "ports" in data + assert data["ports"][0]["ifName"] == "GigabitEthernet0/1" + + def test_get_ports_returns_false_on_auth_error(self, mock_server): + mock_server.auth_error_response(path="/api/v0/devices/1/ports") + api = _make_api(mock_server.url) + + success, _ = api.get_ports(1) + + assert success is False + + def test_get_ports_empty_list_when_no_ports(self, mock_server): + mock_server.register("/api/v0/devices/99/ports", {"status": "ok", "ports": []}) + api = _make_api(mock_server.url) + + success, data = api.get_ports(99) + + assert success is True + assert data["ports"] == [] + + +class TestLibreNMSAPIDeviceInfo: + """LibreNMSAPI.get_device_info() correctly parses device details.""" + + def test_returns_device_info_dict(self, mock_server): + mock_server.device_info_response(device_id=5, hostname="rtr01", hardware="ISR4351") + api = _make_api(mock_server.url) + + success, info = api.get_device_info(5) + + assert success is True + assert isinstance(info, dict) + assert info["hostname"] == "rtr01" + + def test_returns_false_on_404(self, mock_server): + # /api/v0/devices/999 not registered β†’ 404 + api = _make_api(mock_server.url) + + success, _ = api.get_device_info(999) + + assert success is False + + +class TestLibreNMSAPIAddDevice: + """LibreNMSAPI.add_device() posts correctly and interprets the response.""" + + def test_add_device_success(self, mock_server): + mock_server.add_device_response(device_id=10) + api = _make_api(mock_server.url) + + success, message = api.add_device( + { + "hostname": "switch1.example.com", + "snmp_version": "v2c", + "community": "public", + "force_add": False, + } + ) + + assert success is True + + def test_add_device_failure_on_server_error(self, mock_server): + mock_server.register("/api/v0/devices", {"status": "error", "message": "duplicate"}, status=500) + api = _make_api(mock_server.url) + + success, message = api.add_device( + { + "hostname": "dup.example.com", + "snmp_version": "v2c", + "community": "public", + } + ) + + assert success is False + + +class TestLibreNMSAPIInventory: + """LibreNMSAPI.get_device_inventory() correctly parses mock server responses.""" + + def test_returns_inventory_list(self, mock_server): + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalDescr": "Chassis", + "entPhysicalClass": "chassis", + "entPhysicalSerialNum": "SN-CHASSIS-001", + "entPhysicalModelName": "WS-C4900M", + "entPhysicalName": "Chassis 1", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalDescr": "Linecard", + "entPhysicalClass": "module", + "entPhysicalSerialNum": "SN-CARD-002", + "entPhysicalModelName": "WS-X4748-RJ45V+E", + "entPhysicalName": "Slot 1", + "entPhysicalContainedIn": 1, + }, + ] + mock_server.register("/api/v0/inventory/7/all", {"status": "ok", "inventory": inventory}) + api = _make_api(mock_server.url) + + success, data = api.get_device_inventory(7) + + assert success is True + assert isinstance(data, list) + assert len(data) == 2 + assert data[0]["entPhysicalClass"] == "chassis" + assert data[1]["entPhysicalModelName"] == "WS-X4748-RJ45V+E" + + def test_returns_empty_list_when_no_inventory(self, mock_server): + mock_server.register("/api/v0/inventory/99/all", {"status": "ok", "inventory": []}) + api = _make_api(mock_server.url) + + success, data = api.get_device_inventory(99) + + assert success is True + assert data == [] + + def test_returns_false_on_network_error(self, mock_server): + # Unregistered path β†’ 404 β†’ raise_for_status β†’ RequestException + api = _make_api(mock_server.url) + + success, _ = api.get_device_inventory(404) + + assert success is False + + def test_inventory_items_preserve_all_fields(self, mock_server): + inventory = [ + { + "entPhysicalIndex": 5, + "entPhysicalDescr": "10 Gigabit Ethernet Module", + "entPhysicalClass": "module", + "entPhysicalSerialNum": "JAE123XYZ", + "entPhysicalModelName": "X2-10GB-LR", + "entPhysicalName": "TenGigabitEthernet1/1", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 1, + } + ] + mock_server.register("/api/v0/inventory/3/all", {"status": "ok", "inventory": inventory}) + api = _make_api(mock_server.url) + + success, data = api.get_device_inventory(3) + + assert success is True + item = data[0] + assert item["entPhysicalParentRelPos"] == 1 + assert item["entPhysicalSerialNum"] == "JAE123XYZ" diff --git a/netbox_librenms_plugin/tests/test_librenms_id.py b/netbox_librenms_plugin/tests/test_librenms_id.py new file mode 100644 index 0000000000..d5a5b4cc99 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_librenms_id.py @@ -0,0 +1,216 @@ +"""Tests for multi-server librenms_id helpers. + +Covers get_librenms_device_id, find_by_librenms_id, and migrate_legacy_librenms_id. +set_librenms_device_id is already tested in test_utils.py::TestSetLibreNMSDeviceId. +""" + +from unittest.mock import MagicMock + + +class TestGetLibreNMSDeviceId: + """Tests for get_librenms_device_id().""" + + def test_returns_none_when_cf_missing(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {} + result = get_librenms_device_id(obj, "default") + assert result is None + + def test_returns_int_for_legacy_bare_integer(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": 42} + result = get_librenms_device_id(obj, "default") + assert result == 42 + + def test_legacy_bare_int_returned_for_any_server_key(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": 99} + assert get_librenms_device_id(obj, "production") == 99 + assert get_librenms_device_id(obj, "secondary") == 99 + + def test_returns_value_for_matching_server_key(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"production": 7, "secondary": 12}} + assert get_librenms_device_id(obj, "production") == 7 + + def test_returns_none_for_missing_server_key_in_dict(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"production": 7}} + result = get_librenms_device_id(obj, "secondary") + assert result is None + + def test_returns_none_for_unexpected_type(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": "not-an-int-or-dict"} + result = get_librenms_device_id(obj, "default") + assert result is None + + def test_default_server_key_is_default(self): + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"default": 5}} + assert get_librenms_device_id(obj) == 5 + + +class TestFindByLibreNMSId: + """Tests for find_by_librenms_id().""" + + def test_queries_server_key_and_legacy_integer(self): + from unittest.mock import MagicMock + 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, "default") + + mock_model.objects.filter.assert_called_once() + # Verify the Q argument covers both the JSON server-key path and legacy integer path. + call_args = mock_model.objects.filter.call_args + q_arg = call_args[0][0] + q_str = str(q_arg) + assert "librenms_id__default" in q_str, "Expected JSON-scoped server_key lookup in filter" + assert "librenms_id" in q_str, "Expected legacy integer lookup in filter" + + def test_returns_first_matching_object(self): + from netbox_librenms_plugin.utils import find_by_librenms_id + + expected = MagicMock() + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = expected + + result = find_by_librenms_id(mock_model, 42, "default") + assert result is expected + + def test_returns_none_when_not_found(self): + 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, "production") + assert result is None + + def test_default_server_key_is_default(self): + 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) + + # Verify filter was called (Q objects are built internally β€” just confirm call was made) + mock_model.objects.filter.assert_called_once() + + +class TestMigrateLegacyLibreNMSId: + """Tests for migrate_legacy_librenms_id().""" + + def test_returns_true_when_migrated(self): + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 42} + result = migrate_legacy_librenms_id(obj, "default") + assert result is True + + def test_migrates_integer_to_dict_format(self): + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 42} + migrate_legacy_librenms_id(obj, "production") + assert obj.custom_field_data["librenms_id"] == {"production": 42} + + def test_returns_false_when_already_dict(self): + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": 42}} + result = migrate_legacy_librenms_id(obj, "default") + assert result is False + + def test_returns_false_when_value_is_none(self): + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": None} + result = migrate_legacy_librenms_id(obj, "default") + assert result is False + + def test_does_not_call_save(self): + """migrate_legacy_librenms_id must NOT call obj.save() β€” caller is responsible.""" + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 7} + migrate_legacy_librenms_id(obj, "default") + obj.save.assert_not_called() + + def test_preserves_value_in_migrated_dict(self): + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 99} + migrate_legacy_librenms_id(obj, "secondary") + assert obj.custom_field_data["librenms_id"]["secondary"] == 99 + + +class TestLibreNMSIdRoundtrip: + """get_librenms_device_id should see the value set by set_librenms_device_id.""" + + def test_set_then_get_returns_same_value(self): + from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {} + obj.cf = obj.custom_field_data # make cf a live view of custom_field_data + + set_librenms_device_id(obj, 42, "production") + result = get_librenms_device_id(obj, "production") + assert result == 42 + + def test_set_multiple_servers_get_correct_each(self): + from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {} + obj.cf = obj.custom_field_data + + set_librenms_device_id(obj, 10, "primary") + set_librenms_device_id(obj, 20, "secondary") + + assert get_librenms_device_id(obj, "primary") == 10 + assert get_librenms_device_id(obj, "secondary") == 20 + + def test_migrate_then_get_returns_value(self): + from netbox_librenms_plugin.utils import get_librenms_device_id, migrate_legacy_librenms_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 55} + obj.cf = obj.custom_field_data + + migrate_legacy_librenms_id(obj, "default") + result = get_librenms_device_id(obj, "default") + assert result == 55 diff --git a/netbox_librenms_plugin/tests/test_mixins.py b/netbox_librenms_plugin/tests/test_mixins.py new file mode 100644 index 0000000000..9db2f7d2ba --- /dev/null +++ b/netbox_librenms_plugin/tests/test_mixins.py @@ -0,0 +1,173 @@ +"""Tests for view mixins: LibreNMSAPIMixin and CacheMixin.""" + +from unittest.mock import MagicMock, patch + + +class TestLibreNMSAPIMixinLazyInit: + """LibreNMSAPIMixin.librenms_api is lazy β€” not created until first access.""" + + def _make_mixin(self): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + mixin = object.__new__(LibreNMSAPIMixin) + mixin._librenms_api = None + return mixin + + def test_starts_with_none(self): + mixin = self._make_mixin() + assert mixin._librenms_api is None + + def test_first_access_creates_instance(self): + mixin = self._make_mixin() + fake_api = MagicMock() + + with patch("netbox_librenms_plugin.views.mixins.LibreNMSAPI", return_value=fake_api): + api = mixin.librenms_api + + assert api is fake_api + + def test_second_access_returns_same_instance(self): + mixin = self._make_mixin() + fake_api = MagicMock() + + with patch("netbox_librenms_plugin.views.mixins.LibreNMSAPI", return_value=fake_api) as mock_cls: + api1 = mixin.librenms_api + api2 = mixin.librenms_api + + assert api1 is api2 + mock_cls.assert_called_once() # constructor called only once + + def test_librenms_api_is_property_descriptor(self): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert isinstance(LibreNMSAPIMixin.__dict__["librenms_api"], property) + + +class TestLibreNMSAPIMixinGetServerInfo: + """get_server_info() returns correct structure for multi-server and legacy configs.""" + + def _make_mixin_with_api(self, server_key="default"): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + mixin = object.__new__(LibreNMSAPIMixin) + fake_api = MagicMock() + fake_api.server_key = server_key + mixin._librenms_api = fake_api + return mixin + + def test_multi_server_returns_display_name_and_url(self): + mixin = self._make_mixin_with_api("production") + + servers = { + "production": { + "display_name": "Production LibreNMS", + "librenms_url": "https://librenms.example.com", + } + } + + with patch("netbox.plugins.get_plugin_config") as mock_config: + mock_config.side_effect = lambda _plugin, key: servers if key == "servers" else None + info = mixin.get_server_info() + + assert info["display_name"] == "Production LibreNMS" + assert info["url"] == "https://librenms.example.com" + assert info["is_legacy"] is False + assert info["server_key"] == "production" + + def test_legacy_config_sets_is_legacy_true(self): + mixin = self._make_mixin_with_api("default") + + def mock_plugin_config(_plugin, key): + if key == "servers": + return None + if key == "librenms_url": + return "https://legacy.example.com" + return None + + with patch("netbox.plugins.get_plugin_config", side_effect=mock_plugin_config): + info = mixin.get_server_info() + + assert info["is_legacy"] is True + assert info["url"] == "https://legacy.example.com" + + def test_returns_error_info_on_exception(self): + mixin = self._make_mixin_with_api("default") + + with patch("netbox.plugins.get_plugin_config", side_effect=ImportError): + info = mixin.get_server_info() + + assert "is_legacy" in info + assert info["is_legacy"] is True + + +class TestCacheMixinKeyGeneration: + """CacheMixin generates consistent, predictable cache keys.""" + + def _make_mixin(self): + from netbox_librenms_plugin.views.mixins import CacheMixin + + return object.__new__(CacheMixin) + + def test_get_cache_key_format(self): + mixin = self._make_mixin() + obj = MagicMock() + obj._meta.model_name = "device" + obj.pk = 5 + + key = mixin.get_cache_key(obj, "ports") + assert key == "librenms_ports_device_5" + + def test_get_cache_key_includes_model_name(self): + mixin = self._make_mixin() + obj = MagicMock() + obj._meta.model_name = "virtualmachine" + obj.pk = 10 + + key = mixin.get_cache_key(obj, "interfaces") + assert "virtualmachine" in key + assert "10" in key + + def test_get_cache_key_different_data_types(self): + mixin = self._make_mixin() + obj = MagicMock() + obj._meta.model_name = "device" + obj.pk = 1 + + key_ports = mixin.get_cache_key(obj, "ports") + key_ips = mixin.get_cache_key(obj, "ips") + assert key_ports != key_ips + + def test_get_last_fetched_key_format(self): + mixin = self._make_mixin() + obj = MagicMock() + obj._meta.model_name = "device" + obj.pk = 3 + + key = mixin.get_last_fetched_key(obj, "ports") + # Should include "last_fetched" and the object identifiers + assert "last_fetched" in key + assert "device" in key + assert "3" in key + + def test_cache_key_different_pks_differ(self): + mixin = self._make_mixin() + obj1 = MagicMock() + obj1._meta.model_name = "device" + obj1.pk = 1 + + obj2 = MagicMock() + obj2._meta.model_name = "device" + obj2.pk = 2 + + assert mixin.get_cache_key(obj1, "ports") != mixin.get_cache_key(obj2, "ports") + + def test_get_vlan_overrides_key_exists_and_differs_from_data_key(self): + mixin = self._make_mixin() + obj = MagicMock() + obj._meta.model_name = "device" + obj.pk = 7 + + assert hasattr(mixin, "get_vlan_overrides_key"), "CacheMixin must implement get_vlan_overrides_key" + vlan_key = mixin.get_vlan_overrides_key(obj) + data_key = mixin.get_cache_key(obj, "vlans") + assert vlan_key != data_key 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..eb93b969e1 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_modules_view.py @@ -0,0 +1,466 @@ +"""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): + """Call _build_context with all DB-accessing calls mocked out.""" + 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) + + with ( + patch("netbox_librenms_plugin.views.base.modules_view.cache") as mock_cache, + patch("netbox_librenms_plugin.utils.apply_normalization_rules", side_effect=lambda v, *a, **kw: v), + patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False), + patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False), + patch("netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False), + patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=True), + patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False), + patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping, + ): + mock_cache.ttl = MagicMock(return_value=None) + mock_qs = MagicMock() + mock_qs.__iter__ = lambda s: iter([]) + mock_qs.first.return_value = None + mock_mapping.objects.filter.return_value = mock_qs + + # Inline import: patch ModuleBayMapping inside models module + view._build_context(MagicMock(), MagicMock(), inventory_data) + + return rows_store.get("rows", []) + + +# --------------------------------------------------------------------------- +# 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" + + cvr2_module = MagicMock() + cvr2_module.pk = 200 + cvr2_module.serial = "FDO_CVR2" + + glc_te_installed = MagicMock() + glc_te_installed.serial = "MTC213403BB" + glc_te_installed.get_absolute_url.return_value = "/modules/99/" + + # --- 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.model = "WS-X4908" + mt_cvr = MagicMock() + mt_cvr.model = "CVR-X2-SFP" + mt_glc_te = MagicMock() + mt_glc_te.model = "GLC-TE" + mt_glc_t = MagicMock() + 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" + + 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/" + 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" + + +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 = {} + for item in inventory: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, 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 = {} + for item in inventory: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + view = self._view() + results = [] + view._collect_descendants(0, children_by_parent, 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): + view = self._view() + assert view._determine_status(MagicMock(), MagicMock(), "S1") == "Matched" + + def test_no_bay(self): + view = self._view() + assert view._determine_status(None, MagicMock(), "S1") == "No Bay" + + def test_no_type(self): + view = self._view() + assert view._determine_status(MagicMock(), None, "S1") == "No Type" + + def test_unmatched_fallback(self): + view = self._view() + # matched_bay but no matched_type handled by No Type branch first + assert view._determine_status(None, None, "S1") == "No Bay" diff --git a/netbox_librenms_plugin/tests/test_permissions.py b/netbox_librenms_plugin/tests/test_permissions.py index 50bc2b6d04..bb6ea62caf 100644 --- a/netbox_librenms_plugin/tests/test_permissions.py +++ b/netbox_librenms_plugin/tests/test_permissions.py @@ -951,3 +951,130 @@ def test_delete_interfaces_invalid_type_raises_404(self): view = DeleteNetBoxInterfacesView() with pytest.raises(Http404): view.get_required_permissions_for_object_type("invalid") + + +# --------------------------------------------------------------------------- +# Tests for RemoveServerMappingView error handling (device_fields.py) +# --------------------------------------------------------------------------- + + +class TestRemoveServerMappingViewErrorHandling: + """Test RemoveServerMappingView handles full_clean/save failures gracefully.""" + + def _make_view(self, server_key, post_extra=None): + """Return a (view, request) pair with permissions satisfied.""" + from unittest.mock import MagicMock + + from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView + + request = MagicMock() + request.POST = {"server_key": server_key, **(post_extra or {})} + request.user = MagicMock() + request.user.has_perm.return_value = True + + view = RemoveServerMappingView() + view.request = request # required by mixin's has_write_permission + return view, request + + def test_validation_error_returns_error_message(self): + """ValidationError from full_clean leads to error message, not 500.""" + from unittest.mock import MagicMock, patch + + from django.core.exceptions import ValidationError + + view, request = self._make_view(server_key="orphan-server") + + mock_device = MagicMock() + mock_device.custom_field_data = {"librenms_id": {"orphan-server": 99}} + + mock_locked = MagicMock() + mock_locked.custom_field_data = {"librenms_id": {"orphan-server": 99}} + mock_locked.full_clean.side_effect = ValidationError("CF validation failed") + + plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} # orphan-server NOT configured + + 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.Device") as mock_Device_cls, + patch("django.conf.settings") as mock_settings, + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction") as mock_tx, + ): + mock_settings.PLUGINS_CONFIG = plugins_cfg + mock_Device_cls.objects.select_for_update.return_value.get.return_value = mock_locked + + # Make transaction.atomic() a no-op context manager + mock_tx.atomic.return_value.__enter__ = lambda s: None + mock_tx.atomic.return_value.__exit__ = lambda s, *a: None + mock_tx.set_rollback = MagicMock() + + view.post(request, pk=1) + + mock_messages.error.assert_called_once() + error_args = mock_messages.error.call_args[0] + assert "Validation error" in str(error_args[1]) or "CF validation failed" in str(error_args[1]) + + def test_configured_server_refused(self): + """Configured server mapping cannot be removed β€” error message shown.""" + from unittest.mock import MagicMock, patch + + view, request = self._make_view(server_key="active-server") + mock_device = MagicMock() + mock_device.custom_field_data = {"librenms_id": {"active-server": 5}} + + plugins_cfg = {"netbox_librenms_plugin": {"servers": {"active-server": {"librenms_url": "http://x"}}}} + + with ( + patch("netbox_librenms_plugin.views.sync.device_fields.get_object_or_404", return_value=mock_device), + patch("django.conf.settings") as mock_settings, + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + ): + mock_settings.PLUGINS_CONFIG = plugins_cfg + view.post(request, pk=1) + + mock_messages.error.assert_called_once() + assert "Cannot remove" in mock_messages.error.call_args[0][1] + + def test_successful_removal_mutates_and_saves(self): + """Successful removal deletes the key from custom_field_data and saves the device.""" + from unittest.mock import MagicMock, patch + + view, request = self._make_view(server_key="orphan-server") + + mock_device = MagicMock() + mock_device.custom_field_data = {"librenms_id": {"orphan-server": 42, "other-server": 7}} + + mock_locked = MagicMock() + mock_locked.custom_field_data = {"librenms_id": {"orphan-server": 42, "other-server": 7}} + mock_locked.full_clean = MagicMock() + mock_locked.save = MagicMock() + + plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} # orphan-server NOT configured + + 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.Device") as mock_Device_cls, + patch("django.conf.settings") as mock_settings, + patch("netbox_librenms_plugin.views.sync.device_fields.messages") as mock_messages, + patch("netbox_librenms_plugin.views.sync.device_fields.redirect"), + patch("netbox_librenms_plugin.views.sync.device_fields.transaction") as mock_tx, + ): + mock_settings.PLUGINS_CONFIG = plugins_cfg + mock_Device_cls.objects.select_for_update.return_value.get.return_value = mock_locked + + mock_tx.atomic.return_value.__enter__ = lambda s: None + mock_tx.atomic.return_value.__exit__ = lambda s, *a: None + mock_tx.set_rollback = MagicMock() + + view.post(request, pk=1) + + # The "orphan-server" key should have been removed and the device saved. + # Assert the exact shape of custom_field_data so misspelled keys are caught. + assert mock_locked.custom_field_data == {"librenms_id": {"other-server": 7}} + remaining = mock_locked.custom_field_data["librenms_id"] + assert "orphan-server" not in remaining + assert remaining.get("other-server") == 7 # sibling key preserved + mock_locked.save.assert_called_once() + mock_messages.success.assert_called_once() diff --git a/netbox_librenms_plugin/tests/test_sync_devices.py b/netbox_librenms_plugin/tests/test_sync_devices.py new file mode 100644 index 0000000000..c1a36af2d0 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_sync_devices.py @@ -0,0 +1,219 @@ +"""Tests for device sync views: AddDeviceToLibreNMSView and field update views.""" + +from unittest.mock import MagicMock, patch + + +def _make_view(cls_name, module_path="netbox_librenms_plugin.views.sync.devices"): + import importlib + + mod = importlib.import_module(module_path) + cls = getattr(mod, cls_name) + view = object.__new__(cls) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view.request = MagicMock() + return view + + +def _make_field_view(cls_name): + return _make_view(cls_name, "netbox_librenms_plugin.views.sync.device_fields") + + +class TestAddDeviceToLibreNMSViewWiring: + """AddDeviceToLibreNMSView must be correctly wired to LibreNMSAPIMixin.""" + + def test_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert LibreNMSAPIMixin in AddDeviceToLibreNMSView.__mro__ + + def test_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + assert LibreNMSPermissionMixin in AddDeviceToLibreNMSView.__mro__ + + +class TestAddDeviceToLibreNMSViewFormValid: + """form_valid() builds correct device_data payload and calls librenms_api.add_device.""" + + def _make_view(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + + view = object.__new__(AddDeviceToLibreNMSView) + view._librenms_api = MagicMock() + view.request = MagicMock() + view.object = MagicMock() + view.object.get_absolute_url.return_value = "/dcim/devices/1/" + return view + + def _make_form(self, data): + form = MagicMock() + form.cleaned_data = data + return form + + def test_v2c_form_includes_community(self): + view = self._make_view() + view._librenms_api.add_device.return_value = (True, "Device added") + form = self._make_form( + { + "hostname": "switch1.example.com", + "community": "public", + "force_add": False, + } + ) + + with patch("netbox_librenms_plugin.views.sync.devices.redirect"): + with patch("netbox_librenms_plugin.views.sync.devices.messages"): + view.form_valid(form, snmp_version="v2c") + + call_args = view._librenms_api.add_device.call_args[0][0] + assert call_args["snmp_version"] == "v2c" + assert call_args["community"] == "public" + assert call_args["hostname"] == "switch1.example.com" + + def test_v3_form_includes_auth_fields(self): + view = self._make_view() + view._librenms_api.add_device.return_value = (True, "Device added") + form = self._make_form( + { + "hostname": "switch2.example.com", + "authlevel": "authPriv", + "authname": "admin", + "authpass": "secret", + "authalgo": "SHA", + "cryptopass": "crypt", + "cryptoalgo": "AES", + "force_add": False, + } + ) + + with patch("netbox_librenms_plugin.views.sync.devices.redirect"): + with patch("netbox_librenms_plugin.views.sync.devices.messages"): + view.form_valid(form, snmp_version="v3") + + call_args = view._librenms_api.add_device.call_args[0][0] + assert call_args["snmp_version"] == "v3" + assert call_args["authlevel"] == "authPriv" + assert "community" not in call_args + + def test_api_failure_adds_error_message(self): + view = self._make_view() + view._librenms_api.add_device.return_value = (False, "Connection refused") + + form = self._make_form( + { + "hostname": "fail.example.com", + "community": "public", + "force_add": False, + } + ) + + with patch("netbox_librenms_plugin.views.sync.devices.redirect"): + with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg: + view.form_valid(form, snmp_version="v2c") + + mock_msg.error.assert_called_once() + + +class TestUpdateDeviceLocationView: + """UpdateDeviceLocationView.post calls update_device_field with site name.""" + + def test_calls_update_device_field_with_site(self): + from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView + + view = object.__new__(UpdateDeviceLocationView) + view._librenms_api = MagicMock() + view._librenms_api.get_librenms_id.return_value = 42 + view._librenms_api.update_device_field.return_value = (True, "ok") + view.request = MagicMock() + + device = MagicMock() + device.site = MagicMock() + device.site.name = "London" + device.get_absolute_url.return_value = "/dcim/devices/1/" + + with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=device): + with patch("netbox_librenms_plugin.views.sync.devices.redirect"): + with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg: + view.post(view.request, pk=1) + + view._librenms_api.update_device_field.assert_called_once() + call_args = view._librenms_api.update_device_field.call_args + assert 42 in call_args[0] + mock_msg.success.assert_called_once() + + def test_warning_when_no_site(self): + from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView + + view = object.__new__(UpdateDeviceLocationView) + view._librenms_api = MagicMock() + view._librenms_api.get_librenms_id.return_value = 42 + view.request = MagicMock() + + device = MagicMock() + device.site = None + device.pk = 1 + + with patch("netbox_librenms_plugin.views.sync.devices.get_object_or_404", return_value=device): + with patch("netbox_librenms_plugin.views.sync.devices.redirect"): + with patch("netbox_librenms_plugin.views.sync.devices.messages") as mock_msg: + view.post(view.request, pk=1) + + view._librenms_api.update_device_field.assert_not_called() + mock_msg.warning.assert_called_once() + + +class TestUpdateDeviceNameViewWiring: + def test_has_all_required_mixins(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView + from netbox_librenms_plugin.views.mixins import ( + LibreNMSAPIMixin, + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + mro = UpdateDeviceNameView.__mro__ + assert LibreNMSAPIMixin in mro + assert LibreNMSPermissionMixin in mro + assert NetBoxObjectPermissionMixin in mro + + def test_requires_change_device_permission(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView + from dcim.models import Device + + perms = UpdateDeviceNameView.required_object_permissions + assert "POST" in perms + assert any(action == "change" and model == Device for action, model in perms["POST"]) + + +class TestUpdateDeviceSerialViewWiring: + def test_has_all_required_mixins(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert LibreNMSAPIMixin in UpdateDeviceSerialView.__mro__ + + def test_requires_change_device_permission(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView + from dcim.models import Device + + perms = UpdateDeviceSerialView.required_object_permissions + assert "POST" in perms + assert any(action == "change" and model == Device for action, model in perms["POST"]) + + +class TestRemoveServerMappingViewWiring: + def test_does_not_have_librenms_api_mixin(self): + """RemoveServerMappingView does not call LibreNMS API β€” it only modifies NetBox.""" + from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert LibreNMSAPIMixin not in RemoveServerMappingView.__mro__ + + def test_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + assert LibreNMSPermissionMixin in RemoveServerMappingView.__mro__ diff --git a/netbox_librenms_plugin/tests/test_sync_interfaces.py b/netbox_librenms_plugin/tests/test_sync_interfaces.py new file mode 100644 index 0000000000..78a1877766 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_sync_interfaces.py @@ -0,0 +1,296 @@ +"""Unit tests for SyncInterfacesView: update_interface_attributes and handle_mac_address.""" + +from unittest.mock import MagicMock, call, patch + + +def _make_view(): + """Return a SyncInterfacesView with a mocked LibreNMS API.""" + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + view = object.__new__(SyncInterfacesView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = "default" + view.request = MagicMock() + view._lookup_maps = {} + return view + + +class TestUpdateInterfaceAttributes: + """update_interface_attributes() must set fields respecting exclude_columns.""" + + def _make_device_interface(self, **extra): + """Return a MagicMock mimicking a dcim.Interface.""" + from dcim.models import Interface # noqa: F401 + + iface = MagicMock( + spec=[ + "name", + "type", + "speed", + "description", + "mtu", + "enabled", + "save", + "cf", + "custom_field_data", + "mac_addresses", + "primary_mac_address", + ] + ) + iface.cf = {"librenms_id": {"default": 1}} + iface.__class__ = Interface + for k, v in extra.items(): + setattr(iface, k, v) + return iface + + def test_sets_speed_via_convert(self): + view = _make_view() + iface = self._make_device_interface() + librenms_data = {"ifName": "eth0", "ifSpeed": 1_000_000_000} + + with patch( + "netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1_000_000 + ) as mock_convert: + with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id"): + view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName") + + mock_convert.assert_called_once_with(1_000_000_000) + assert iface.speed == 1_000_000 + + def test_skips_excluded_columns(self): + view = _make_view() + iface = self._make_device_interface() + librenms_data = {"ifName": "eth0", "ifSpeed": 1_000_000_000, "ifAlias": "uplink"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=1_000_000): + with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id"): + view.update_interface_attributes(iface, librenms_data, "1000base-t", {"speed"}, "ifName") + + # speed should NOT be set (it's excluded) + assert not any(c == call(iface, "speed", 1_000_000) for c in iface.method_calls) + + def test_sets_type_for_device_interface(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "ifType": "ethernetCsmacd"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName") + + assert iface.type == "1000base-t" + + def test_does_not_set_type_for_vm_interface(self): + from virtualization.models import VMInterface + + view = _make_view() + iface = MagicMock() + iface.__class__ = VMInterface + iface.cf = {} + iface.mac_addresses = MagicMock() + original_type = "some_type" + iface.type = original_type + librenms_data = {"ifName": "eth0", "ifType": "ethernetCsmacd"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, "1000base-t", set(), "ifName") + + # type is NOT in the mapping for non-device interfaces (type set only if is_device_interface) + assert iface.type == original_type + + def test_sets_description_only_when_alias_differs_from_name(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + + # ifAlias == interface name field value β†’ description should NOT be set + librenms_data = {"ifName": "eth0", "ifAlias": "eth0"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + assert iface.description != "eth0" # not set because alias == name + + def test_sets_description_when_alias_differs(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + + librenms_data = {"ifName": "eth0", "ifAlias": "uplink-port"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + assert iface.description == "uplink-port" + + def test_sets_librenms_id_when_port_id_present(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {"librenms_id": {"default": 1}} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "port_id": 77} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set: + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + mock_set.assert_called_once_with(iface, 77, "default") + + def test_does_not_set_librenms_id_when_port_id_none(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {"librenms_id": {"default": 1}} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "port_id": None} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + with patch("netbox_librenms_plugin.views.sync.interfaces.set_librenms_device_id") as mock_set: + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + mock_set.assert_not_called() + + def test_sets_enabled_true_when_admin_status_none(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "ifAdminStatus": None} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + assert iface.enabled is True + + def test_sets_enabled_based_on_admin_status_string(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "ifAdminStatus": "down"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + assert iface.enabled is False + + def test_calls_save_at_end(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + view.update_interface_attributes(iface, librenms_data, None, {"type", "speed", "mtu"}, "ifName") + + iface.save.assert_called_once() + + def test_excludes_mac_address_when_in_excluded(self): + from dcim.models import Interface + + view = _make_view() + iface = MagicMock() + iface.__class__ = Interface + iface.cf = {} + iface.mac_addresses = MagicMock() + librenms_data = {"ifName": "eth0", "ifPhysAddress": "aa:bb:cc:dd:ee:ff"} + + with patch("netbox_librenms_plugin.views.sync.interfaces.convert_speed_to_kbps", return_value=None): + with patch.object(view, "handle_mac_address") as mock_mac: + view.update_interface_attributes(iface, librenms_data, None, {"mac_address"}, "ifName") + + mock_mac.assert_not_called() + + +class TestHandleMacAddress: + """handle_mac_address() must work for both Interface (has primary_mac_address) + and VMInterface (does not have primary_mac_address).""" + + def test_creates_new_mac_and_adds_to_interface(self): + view = _make_view() + iface = MagicMock() + iface.mac_addresses = MagicMock() + iface.mac_addresses.filter.return_value.first.return_value = None + new_mac = MagicMock() + + with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls: + mock_cls.objects.create.return_value = new_mac + view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff") + + mock_cls.objects.create.assert_called_once_with(mac_address="aa:bb:cc:dd:ee:ff") + iface.mac_addresses.add.assert_called_once_with(new_mac) + + def test_reuses_existing_mac(self): + view = _make_view() + existing_mac = MagicMock() + iface = MagicMock() + iface.mac_addresses = MagicMock() + iface.mac_addresses.filter.return_value.first.return_value = existing_mac + + with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls: + view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff") + + mock_cls.objects.create.assert_not_called() + iface.mac_addresses.add.assert_called_once_with(existing_mac) + + def test_sets_primary_mac_when_attribute_present(self): + view = _make_view() + mac_obj = MagicMock() + iface = MagicMock(spec=["mac_addresses", "primary_mac_address"]) + iface.mac_addresses = MagicMock() + iface.mac_addresses.filter.return_value.first.return_value = None + + with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls: + mock_cls.objects.create.return_value = mac_obj + view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff") + + assert iface.primary_mac_address is mac_obj + + def test_no_error_when_primary_mac_attribute_absent(self): + """VMInterface does not have primary_mac_address β€” handle_mac_address must not raise.""" + view = _make_view() + mac_obj = MagicMock() + iface = MagicMock(spec=["mac_addresses"]) # no primary_mac_address attr + iface.mac_addresses = MagicMock() + iface.mac_addresses.filter.return_value.first.return_value = None + + with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls: + mock_cls.objects.create.return_value = mac_obj + # Must not raise AttributeError + view.handle_mac_address(iface, "aa:bb:cc:dd:ee:ff") + + def test_noop_when_mac_address_is_falsy(self): + view = _make_view() + iface = MagicMock() + with patch("netbox_librenms_plugin.views.sync.interfaces.MACAddress") as mock_cls: + view.handle_mac_address(iface, "") + view.handle_mac_address(iface, None) + + mock_cls.objects.create.assert_not_called() 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..0542d1794d --- /dev/null +++ b/netbox_librenms_plugin/tests/test_sync_modules.py @@ -0,0 +1,1051 @@ +"""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. +""" + +from unittest.mock import MagicMock, patch + + +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 TestInstallBranchViewGetModuleTypes: + """_get_module_types builds a dict keyed by model name, part number, and mappings.""" + + def test_indexes_by_model_and_part_number(self): + 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_mt_cls = MagicMock() + mock_mt_cls.objects.all.return_value.select_related.return_value = [mt1, mt2] + + mock_map_cls = MagicMock() + mock_map_cls.objects.select_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): + view = _make_install_branch_view() + result = view._get_module_types() + + 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 + + +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 must NOT be defined in views/base anymore.""" + import importlib + + mod = importlib.import_module("netbox_librenms_plugin.views.base.modules_view") + assert not hasattr(mod, "InstallModuleView"), ( + "InstallModuleView must have been moved out of views/base/modules_view.py" + ) + + +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 + + +def _bay(name, installed_module=None, pk=None): + """Quick MagicMock module bay.""" + bay = MagicMock() + bay.name = name + bay.pk = pk or id(bay) + bay.installed_module = installed_module + bay.get_absolute_url.return_value = f"/dcim/module-bays/{bay.pk}/" + return bay + + +def _module(serial="SN001"): + mod = MagicMock() + mod.serial = serial + mod.get_absolute_url.return_value = "/dcim/modules/1/" + return mod + + +# --------------------------------------------------------------------------- +# _determine_status +# --------------------------------------------------------------------------- + + +class TestDetermineStatus: + """_determine_status returns the correct badge string for every combination.""" + + def test_matched_bay_and_type(self): + view = _make_base_view() + assert view._determine_status(MagicMock(), MagicMock(), "") == "Matched" + + def test_no_bay_regardless_of_type(self): + view = _make_base_view() + assert view._determine_status(None, MagicMock(), "") == "No Bay" + assert view._determine_status(None, None, "") == "No Bay" + + def test_bay_without_type(self): + view = _make_base_view() + assert view._determine_status(MagicMock(), None, "") == "No Type" + + def test_unmatched_when_neither(self): + # This path is unreachable via current code (No Bay catches it first), + # but _determine_status is a standalone method so test the logic directly. + view = _make_base_view() + # Trick: pass a falsy non-None bay to skip "no bay" but reach "no type" + # Not possible with current logic; just verify No Bay path dominates. + assert view._determine_status(None, None, "SN1") == "No Bay" + + +# --------------------------------------------------------------------------- +# 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) + + module_bays = {"Slot 1": bay} + module_types = {"WS-X4748": mt} + index_map = {10: item} + + with patch.object(view, "_match_module_bay", return_value=bay): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + row = view._build_row(item, index_map, module_bays, module_types, depth=0) + + assert row["status"] == "Installed" + assert row["row_class"] == "table-success" + + 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) + + module_bays = {"Slot 1": bay} + module_types = {"WS-X4748": mt} + index_map = {10: item} + + with patch.object(view, "_match_module_bay", return_value=bay): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + row = view._build_row(item, index_map, module_bays, module_types, depth=0) + + assert row["status"] == "Serial Mismatch" + assert row["row_class"] == "table-danger" + + 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.object(view, "_match_module_bay", return_value=None): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + 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.object(view, "_match_module_bay", return_value=bay): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="UNKNOWN-MODEL"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + 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 with no installed module + bay = _bay("Slot 1", installed_module=None) + bay.installed_module = None + + with patch.object(view, "_match_module_bay", return_value=bay): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + 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.object(view, "_match_module_bay", return_value=bay): + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.utils.module_type_uses_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.supports_module_path", return_value=False): + with patch("netbox_librenms_plugin.utils.has_nested_name_conflict", return_value=False): + with patch("netbox_librenms_plugin.utils.module_type_is_end_module", return_value=False): + with patch( + "netbox_librenms_plugin.utils.module_type_uses_module_token", return_value=False + ): + row = view._build_row(item, {10: item}, {"Slot 1": bay}, {"WS-X4748": mt}, depth=0) + + assert row["can_install"] is False + + +# --------------------------------------------------------------------------- +# 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", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + } + container1 = { + "entPhysicalIndex": 2, + "entPhysicalModelName": "", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 1, + } + container2 = { + "entPhysicalIndex": 3, + "entPhysicalModelName": "", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 2, + } + sfp1 = { + "entPhysicalIndex": 4, + "entPhysicalModelName": "SFP-10G-LR", + "entPhysicalContainedIn": 2, + "entPhysicalParentRelPos": 1, + } + sfp2 = { + "entPhysicalIndex": 5, + "entPhysicalModelName": "SFP-10G-SR", + "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", + "entPhysicalContainedIn": 0, + "entPhysicalParentRelPos": 0, + } + container = { + "entPhysicalIndex": 2, + "entPhysicalModelName": "", + "entPhysicalContainedIn": 1, + "entPhysicalParentRelPos": 1, + } + item = { + "entPhysicalIndex": 3, + "entPhysicalModelName": "X", + "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.models.ModuleBayMapping") as mock_mbm: + mock_mbm.objects.filter.return_value.first.return_value = None + mock_mbm.objects.filter.return_value = MagicMock() + 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.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.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] + 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 + + 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.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls: + mock_mapping_cls.objects.all.return_value = [] + with patch.object(view, "_find_parent_module_id", return_value=None): + with patch.object(view, "_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.apply_normalization_rules", return_value="WS-X4748"): + with patch.object(view, "_find_parent_module_id", 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): + 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.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls: + mock_mapping_cls.objects.all.return_value = [] + with patch.object(view, "_find_parent_module_id", return_value=None): + with patch.object(view, "_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): + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + bay.installed_module = _module() # occupied! + + with patch("netbox_librenms_plugin.utils.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls: + mock_mapping_cls.objects.all.return_value = [] + with patch.object(view, "_find_parent_module_id", return_value=None): + with patch.object(view, "_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 + + view = _make_install_branch_view() + device, item, index_map, module_types, ModuleBay, ModuleType, Module, bay, mt = self._make_args() + Module.side_effect = Exception("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.apply_normalization_rules", return_value="WS-X4748"): + with patch("netbox_librenms_plugin.models.ModuleBayMapping") as mock_mapping_cls: + mock_mapping_cls.objects.all.return_value = [] + with patch.object(view, "_find_parent_module_id", return_value=None): + with patch.object(view, "_match_bay", return_value=bay): + result = view._install_single( + device, item, index_map, module_types, ModuleBay, ModuleType, Module + ) + + assert result["status"] == "failed" + + +# --------------------------------------------------------------------------- +# 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 for 'selection' should be 42 (ent_physical_index), not '' + cell_val = rows[0].get_cell("selection") + assert str(cell_val) != "", "Checkbox cell must not be empty for a record with ent_physical_index" + + +# --------------------------------------------------------------------------- +# Regression: ancestor walk skips containers with N/A model (Cisco 8201 style) +# --------------------------------------------------------------------------- + + +class TestAncestorWalkGenericContainerModel: + """Top-level items under containers with 'N/A' model should not be excluded.""" + + def _run_top_items(self, inventory_data): + from netbox_librenms_plugin.views.base.modules_view import INVENTORY_CLASSES, _GENERIC_CONTAINER_MODELS + + idx_map = { + item["entPhysicalIndex"]: item for item in inventory_data if item.get("entPhysicalIndex") is not None + } + top_items = [] + for item in inventory_data: + phys_class = item.get("entPhysicalClass") + if phys_class not in INVENTORY_CLASSES: + continue + model = (item.get("entPhysicalModelName") or "").strip() + if phys_class == "container" and model in _GENERIC_CONTAINER_MODELS: + continue + if model and model in _GENERIC_CONTAINER_MODELS: + continue + is_descendant = False + current_idx = item.get("entPhysicalContainedIn", 0) + visited_ancestors = set() + while current_idx and current_idx in idx_map and current_idx not in visited_ancestors: + visited_ancestors.add(current_idx) + ancestor = idx_map[current_idx] + anc_class = ancestor.get("entPhysicalClass") + if anc_class in INVENTORY_CLASSES: + anc_model = (ancestor.get("entPhysicalModelName") or "").strip() + if anc_class == "container" and anc_model in _GENERIC_CONTAINER_MODELS: + current_idx = ancestor.get("entPhysicalContainedIn", 0) + continue + is_descendant = True + break + current_idx = ancestor.get("entPhysicalContainedIn", 0) + if is_descendant: + continue + top_items.append(item) + return top_items + + def test_item_under_container_with_na_model_is_top_level(self): + """Module under a container with model='N/A' must appear as top-level item.""" + inventory = [ + # chassis (not in INVENTORY_CLASSES, so ignored in ancestor walk) + { + "entPhysicalIndex": 9000, + "entPhysicalClass": "chassis", + "entPhysicalModelName": "8201-SYS", + "entPhysicalContainedIn": 0, + }, + # container with model='N/A' inside chassis β€” generic slot + { + "entPhysicalIndex": 8000, + "entPhysicalClass": "container", + "entPhysicalModelName": "N/A", + "entPhysicalContainedIn": 9000, + }, + # real module inside the N/A container β€” should be top-level + { + "entPhysicalIndex": 1, + "entPhysicalClass": "module", + "entPhysicalModelName": "8201-SYS", + "entPhysicalContainedIn": 8000, + }, + ] + top = self._run_top_items(inventory) + indices = [i["entPhysicalIndex"] for i in top] + assert 1 in indices, "Module inside N/A container must be a top-level item (Cisco 8201 regression)" + + def test_item_under_container_with_empty_model_is_top_level(self): + """Legacy: module under container with empty model still works.""" + inventory = [ + { + "entPhysicalIndex": 9000, + "entPhysicalClass": "chassis", + "entPhysicalModelName": "8201-SYS", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 8000, + "entPhysicalClass": "container", + "entPhysicalModelName": "", + "entPhysicalContainedIn": 9000, + }, + { + "entPhysicalIndex": 1, + "entPhysicalClass": "module", + "entPhysicalModelName": "8201-SYS", + "entPhysicalContainedIn": 8000, + }, + ] + top = self._run_top_items(inventory) + indices = [i["entPhysicalIndex"] for i in top] + assert 1 in indices + + def test_item_under_real_module_is_excluded(self): + """Module inside another real (non-generic) module stays a descendant.""" + inventory = [ + { + "entPhysicalIndex": 1, + "entPhysicalClass": "module", + "entPhysicalModelName": "PARENT-MODULE", + "entPhysicalContainedIn": 0, + }, + { + "entPhysicalIndex": 2, + "entPhysicalClass": "module", + "entPhysicalModelName": "CHILD-MODULE", + "entPhysicalContainedIn": 1, + }, + ] + top = self._run_top_items(inventory) + indices = [i["entPhysicalIndex"] for i in top] + assert 1 in indices + assert 2 not in indices, "Child module under real parent must remain a descendant" + + +# --------------------------------------------------------------------------- +# Regression: parent_row_idx (table index) must not alias entPhysicalIndex +# --------------------------------------------------------------------------- + + +class TestParentRowIdxVsEntityIndex: + """Regression: parent_row_idx must be used for table_data access, not parent_ent_idx. + + Bug: parent_idx was first set to len(table_data) (a small row index), then + overwritten with item.get("entPhysicalIndex") (which can be millions). + table_data[parent_idx] then indexed the list with the large entity value, + causing IndexError or wrong-row mutations. + """ + + def test_has_installable_children_set_on_correct_row(self): + """has_installable_children must land on table row 0, not on entity index 8_000_000.""" + import importlib + from unittest.mock import MagicMock, patch + + mod = importlib.import_module("netbox_librenms_plugin.views.base.modules_view") + BaseModuleTableView = mod.BaseModuleTableView + + LARGE_IDX = 8_000_000 # >> 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): + 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.object(view, "_get_module_bays", return_value=({}, {})): + with patch.object(view, "_get_module_types", 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): + # 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). + """ + + def test_install_module_view_no_cache_delete_in_source(self): + """InstallModuleView.post body must not contain a cache.delete call.""" + import inspect + + from netbox_librenms_plugin.views.sync.modules import InstallModuleView + + source = inspect.getsource(InstallModuleView.post) + assert "cache.delete" not in source, ( + "InstallModuleView.post must not call cache.delete β€” " + "deleting the inventory cache after install causes an empty modules tab." + ) + + def test_install_branch_view_no_cache_delete_in_source(self): + """InstallBranchView.post body must not contain a cache.delete call.""" + import inspect + + from netbox_librenms_plugin.views.sync.modules import InstallBranchView + + source = inspect.getsource(InstallBranchView.post) + assert "cache.delete" not in source, ( + "InstallBranchView.post must not call cache.delete β€” " + "deleting the inventory cache after install causes an empty modules tab." + ) + + def test_install_selected_view_no_cache_delete_in_source(self): + """InstallSelectedView.post body must not contain a cache.delete call.""" + import inspect + + from netbox_librenms_plugin.views.sync.modules import InstallSelectedView + + source = inspect.getsource(InstallSelectedView.post) + assert "cache.delete" not in source, ( + "InstallSelectedView.post must not call cache.delete β€” " + "deleting the inventory cache after install causes an empty modules tab." + ) diff --git a/netbox_librenms_plugin/tests/test_sync_view_mismatch.py b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py index e59ab6909c..e9a04d2997 100644 --- a/netbox_librenms_plugin/tests/test_sync_view_mismatch.py +++ b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py @@ -318,3 +318,112 @@ def test_vc_pattern_no_match_leaves_name(self, mock_settings_qs, mock_hw): result = view.get_librenms_device_info(obj) assert result["mismatched_device"] is True + + +# --------------------------------------------------------------------------- +# Tests for _build_all_server_mappings +# --------------------------------------------------------------------------- + + +class TestBuildAllServerMappings: + """Tests for BaseLibreNMSSyncView._build_all_server_mappings.""" + + def _make_obj(self, cf_librenms_id): + obj = MagicMock() + obj.custom_field_data = {"librenms_id": cf_librenms_id} + return obj + + def test_returns_none_for_legacy_int(self): + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + obj = self._make_obj(42) + result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "production") + assert result is None + + def test_returns_none_for_missing_cf(self): + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + obj = self._make_obj(None) + result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "production") + assert result is None + + def test_single_configured_server(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + obj = self._make_obj({"production": 42}) + plugins_cfg = { + "netbox_librenms_plugin": { + "servers": { + "production": { + "display_name": "Production LibreNMS", + "librenms_url": "https://librenms.example.com", + }, + } + } + } + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings: + mock_settings.PLUGINS_CONFIG = plugins_cfg + result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "production") + + assert result is not None + assert len(result) == 1 + entry = result[0] + assert entry["server_key"] == "production" + assert entry["device_id"] == 42 + assert entry["display_name"] == "Production LibreNMS" + assert entry["is_configured"] is True + assert entry["is_active"] is True + assert entry["device_url"] == "https://librenms.example.com/device/device=42/" + + def test_orphaned_server_is_not_configured(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + obj = self._make_obj({"deleted-server": 77}) + plugins_cfg = {"netbox_librenms_plugin": {"servers": {}}} + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings: + mock_settings.PLUGINS_CONFIG = plugins_cfg + result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "production") + + assert result is not None + assert len(result) == 1 + entry = result[0] + assert entry["server_key"] == "deleted-server" + assert entry["device_id"] == 77 + assert entry["is_configured"] is False + assert entry["is_active"] is False + assert entry["device_url"] is None + + def test_multiple_servers_sorted_active_first(self): + from unittest.mock import patch + + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + obj = self._make_obj({"mock-dev": 99, "production": 42, "old-server": 11}) + plugins_cfg = { + "netbox_librenms_plugin": { + "servers": { + "production": {"display_name": "Production", "librenms_url": "https://prod.example.com"}, + "mock-dev": {"display_name": "Mock", "librenms_url": "http://mock.example.com"}, + } + } + } + with patch("netbox_librenms_plugin.views.base.librenms_sync_view.django_settings") as mock_settings: + mock_settings.PLUGINS_CONFIG = plugins_cfg + result = BaseLibreNMSSyncView._build_all_server_mappings(obj, "production") + + assert result is not None + assert len(result) == 3 + # Active (production) first + assert result[0]["server_key"] == "production" + assert result[0]["is_active"] is True + # Configured (mock-dev) second + assert result[1]["server_key"] == "mock-dev" + assert result[1]["is_configured"] is True + assert result[1]["is_active"] is False + # Orphaned last + assert result[2]["server_key"] == "old-server" + assert result[2]["is_configured"] is False diff --git a/netbox_librenms_plugin/tests/test_tables_modules.py b/netbox_librenms_plugin/tests/test_tables_modules.py new file mode 100644 index 0000000000..7d1593d521 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_tables_modules.py @@ -0,0 +1,498 @@ +"""Tests for netbox_librenms_plugin.tables.modules module. + +Covers LibreNMSModuleTable render_* methods by calling them directly, +bypassing __init__ with object.__new__. No DB access required. +""" + +from unittest.mock import MagicMock, patch + + +class TestLibreNMSModuleTable: + """Direct unit tests for every render_* method on LibreNMSModuleTable.""" + + def _make_table(self, device=None): + """Create a bare table instance without calling __init__.""" + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = object.__new__(LibreNMSModuleTable) + table.device = device + table.csrf_token = "test-csrf-token" + table.server_key = "" + return table + + # ------------------------------------------------------------------ + # render_name + # ------------------------------------------------------------------ + + def test_render_name_depth_zero_returns_value(self): + """At depth 0 the raw value is returned unchanged.""" + table = self._make_table() + result = table.render_name("Router", {"depth": 0}) + assert result == "Router" + + def test_render_name_depth_zero_none_value_returns_dash(self): + """At depth 0, None value is replaced with '-'.""" + table = self._make_table() + result = table.render_name(None, {"depth": 0}) + assert result == "-" + + def test_render_name_depth_zero_missing_depth_defaults_to_zero(self): + """When 'depth' key is absent the record defaults to 0.""" + table = self._make_table() + result = table.render_name("Switch", {}) + assert result == "Switch" + + def test_render_name_depth_nonzero_contains_indent_and_prefix(self): + """Non-zero depth produces a padded span with tree prefix.""" + table = self._make_table() + result = table.render_name("Card", {"depth": 2}) + result_str = str(result) + assert "padding-left:40px" in result_str # 2 * 20 = 40 + assert "└─" in result_str + assert "Card" in result_str + + def test_render_name_depth_one_correct_padding(self): + """Depth 1 produces 20 px of padding.""" + table = self._make_table() + result = table.render_name("Sub", {"depth": 1}) + result_str = str(result) + assert "padding-left:20px" in result_str + + def test_render_name_depth_nonzero_none_value_shows_dash(self): + """None value at non-zero depth falls back to '-'.""" + table = self._make_table() + result = table.render_name(None, {"depth": 3}) + result_str = str(result) + assert "-" in result_str + + # ------------------------------------------------------------------ + # render_model + # ------------------------------------------------------------------ + + def test_render_model_empty_string_returns_dash(self): + """Empty string value returns '-'.""" + table = self._make_table() + assert table.render_model("", {}) == "-" + + def test_render_model_dash_value_returns_dash(self): + """Literal '-' value returns '-'.""" + table = self._make_table() + assert table.render_model("-", {}) == "-" + + def test_render_model_none_returns_dash(self): + """None value returns '-'.""" + table = self._make_table() + assert table.render_model(None, {}) == "-" + + def test_render_model_with_url_returns_link(self): + """When module_type_url is present, a hyperlink is rendered.""" + table = self._make_table() + result = str(table.render_model("C9300", {"module_type_url": "/dcim/module-types/1/"})) + assert 'href="/dcim/module-types/1/"' in result + assert "C9300" in result + + def test_render_model_without_url_returns_plain_value(self): + """Without a URL the plain value string is returned.""" + table = self._make_table() + result = table.render_model("C9300", {}) + assert result == "C9300" + + # ------------------------------------------------------------------ + # render_serial + # ------------------------------------------------------------------ + + def test_render_serial_empty_returns_dash(self): + """Empty serial returns '-'.""" + table = self._make_table() + assert table.render_serial("", {}) == "-" + + def test_render_serial_none_returns_dash(self): + """None serial returns '-'.""" + table = self._make_table() + assert table.render_serial(None, {}) == "-" + + def test_render_serial_present_returns_value(self): + """Non-empty serial is returned as-is.""" + table = self._make_table() + assert table.render_serial("SN12345", {}) == "SN12345" + + # ------------------------------------------------------------------ + # render_description + # ------------------------------------------------------------------ + + def test_render_description_none_returns_dash(self): + """None description returns '-'.""" + table = self._make_table() + assert table.render_description(None, {}) == "-" + + def test_render_description_empty_string_returns_dash(self): + """Empty string description returns '-'.""" + table = self._make_table() + assert table.render_description("", {}) == "-" + + def test_render_description_short_returns_value(self): + """Short description (≀60 chars) is returned unchanged.""" + table = self._make_table() + short = "Short description" + assert table.render_description(short, {}) == short + + def test_render_description_exactly_60_chars_not_truncated(self): + """Description of exactly 60 chars is returned unchanged.""" + table = self._make_table() + exact = "A" * 60 + assert table.render_description(exact, {}) == exact + + def test_render_description_long_truncated_with_ellipsis(self): + """Description longer than 60 chars is truncated and has hellip.""" + table = self._make_table() + long_desc = "B" * 65 + result = str(table.render_description(long_desc, {})) + assert "B" * 57 in result + assert "…" in result + + def test_render_description_long_title_contains_full_value(self): + """The title attribute of the truncated span contains the full value.""" + table = self._make_table() + long_desc = "C" * 70 + result = str(table.render_description(long_desc, {})) + assert long_desc in result # full value in title attribute + + # ------------------------------------------------------------------ + # render_item_class + # ------------------------------------------------------------------ + + def test_render_item_class_module_uses_expansion_card_icon(self): + """'module' class uses the mdi-expansion-card icon.""" + table = self._make_table() + result = str(table.render_item_class("module", {})) + assert "mdi-expansion-card" in result + assert "module" in result + + def test_render_item_class_fan_uses_fan_icon(self): + """'fan' class uses the mdi-fan icon.""" + table = self._make_table() + result = str(table.render_item_class("fan", {})) + assert "mdi-fan" in result + + def test_render_item_class_power_supply_uses_plug_icon(self): + """'powerSupply' class uses the mdi-power-plug icon.""" + table = self._make_table() + result = str(table.render_item_class("powerSupply", {})) + assert "mdi-power-plug" in result + + def test_render_item_class_port_uses_ethernet_icon(self): + """'port' class uses the mdi-ethernet icon.""" + table = self._make_table() + result = str(table.render_item_class("port", {})) + assert "mdi-ethernet" in result + + def test_render_item_class_unknown_uses_default_icon(self): + """Unknown class falls back to mdi-card-outline icon.""" + table = self._make_table() + result = str(table.render_item_class("unknown_class", {})) + assert "mdi-card-outline" in result + + def test_render_item_class_io_module_variant(self): + """'ioModule' is also mapped to expansion-card.""" + table = self._make_table() + result = str(table.render_item_class("ioModule", {})) + assert "mdi-expansion-card" in result + + # ------------------------------------------------------------------ + # render_module_bay + # ------------------------------------------------------------------ + + def test_render_module_bay_none_shows_no_matching_bay(self): + """None value shows the 'No matching bay' danger span.""" + table = self._make_table() + result = str(table.render_module_bay(None, {})) + assert "text-danger" in result + assert "No matching bay" in result + + def test_render_module_bay_dash_shows_no_matching_bay(self): + """Literal '-' shows the 'No matching bay' danger span.""" + table = self._make_table() + result = str(table.render_module_bay("-", {})) + assert "text-danger" in result + + def test_render_module_bay_empty_shows_no_matching_bay(self): + """Empty string shows the 'No matching bay' danger span.""" + table = self._make_table() + result = str(table.render_module_bay("", {})) + assert "text-danger" in result + + def test_render_module_bay_with_url_renders_link(self): + """When module_bay_url is present, a hyperlink is rendered.""" + table = self._make_table() + result = str(table.render_module_bay("Bay 1", {"module_bay_url": "/dcim/module-bays/5/"})) + assert 'href="/dcim/module-bays/5/"' in result + assert "Bay 1" in result + + def test_render_module_bay_without_url_returns_plain_value(self): + """Without URL the plain bay name is returned.""" + table = self._make_table() + result = table.render_module_bay("Bay 1", {}) + assert result == "Bay 1" + + # ------------------------------------------------------------------ + # render_module_type + # ------------------------------------------------------------------ + + def test_render_module_type_none_shows_no_matching_type(self): + """None value shows the 'No matching type' warning span.""" + table = self._make_table() + result = str(table.render_module_type(None, {})) + assert "text-warning" in result + assert "No matching type" in result + + def test_render_module_type_dash_shows_no_matching_type(self): + """Literal '-' shows the 'No matching type' warning span.""" + table = self._make_table() + result = str(table.render_module_type("-", {})) + assert "text-warning" in result + + def test_render_module_type_with_url_renders_link(self): + """When module_type_url is present, a hyperlink is rendered.""" + table = self._make_table() + result = str(table.render_module_type("C9300-NM-8X", {"module_type_url": "/dcim/module-types/10/"})) + assert 'href="/dcim/module-types/10/"' in result + assert "C9300-NM-8X" in result + + def test_render_module_type_without_url_returns_plain_value(self): + """Without URL the plain type name is returned.""" + table = self._make_table() + result = table.render_module_type("C9300-NM-8X", {}) + assert result == "C9300-NM-8X" + + # ------------------------------------------------------------------ + # render_status + # ------------------------------------------------------------------ + + def test_render_status_installed_uses_success_badge(self): + """'Installed' status renders a bg-success badge.""" + table = self._make_table() + result = str(table.render_status("Installed", {})) + assert "bg-success" in result + assert "Installed" in result + + def test_render_status_matched_uses_info_badge(self): + """'Matched' status renders a bg-info badge.""" + table = self._make_table() + result = str(table.render_status("Matched", {})) + assert "bg-info" in result + + def test_render_status_no_bay_uses_warning_badge(self): + """'No Bay' status renders a bg-warning badge.""" + table = self._make_table() + result = str(table.render_status("No Bay", {})) + assert "bg-warning" in result + + def test_render_status_serial_mismatch_uses_danger_badge(self): + """'Serial Mismatch' status renders a bg-danger badge.""" + table = self._make_table() + result = str(table.render_status("Serial Mismatch", {})) + assert "bg-danger" in result + assert "Serial Mismatch" in result + + def test_render_status_unknown_value_uses_secondary_badge(self): + """Unknown status value falls back to bg-secondary.""" + table = self._make_table() + result = str(table.render_status("Weird Status", {})) + assert "bg-secondary" in result + + def test_render_status_with_module_path_warning_adds_alert_icon(self): + """module_path_warning adds an mdi-alert-outline icon.""" + table = self._make_table() + result = str(table.render_status("Installed", {"module_path_warning": "Upgrade NetBox for module_path"})) + assert "mdi-alert-outline" in result + assert "Upgrade NetBox for module_path" in result + assert "bg-success" in result + + def test_render_status_with_name_conflict_warning_adds_alert_icon(self): + """name_conflict_warning adds an mdi-alert-outline icon with the warning text.""" + table = self._make_table() + result = str( + table.render_status("Name Conflict", {"name_conflict_warning": "Name already used by another module"}) + ) + assert "mdi-alert-outline" in result + assert "Name already used by another module" in result + + def test_render_status_with_module_type_upgrade_hint_adds_info_icon(self): + """module_type_upgrade_hint adds an mdi-information-outline icon.""" + table = self._make_table() + result = str(table.render_status("Matched", {"module_type_upgrade_hint": "Newer module type available"})) + assert "mdi-information-outline" in result + assert "Newer module type available" in result + + def test_render_status_module_path_warning_takes_priority_over_hint(self): + """module_path_warning short-circuits before module_type_upgrade_hint.""" + table = self._make_table() + # Both present β€” path_warning fires first + result = str( + table.render_status( + "Installed", + { + "module_path_warning": "path warning", + "module_type_upgrade_hint": "hint text", + }, + ) + ) + assert "mdi-alert-outline" in result + # hint icon should NOT be present + assert "mdi-information-outline" not in result + + # ------------------------------------------------------------------ + # render_actions + # ------------------------------------------------------------------ + + def test_render_actions_no_device_returns_empty_string(self): + """Returns empty string when no device is set on the table.""" + table = self._make_table(device=None) + result = table.render_actions(None, {"can_install": True}) + assert result == "" + + def test_render_actions_no_buttons_returns_empty_string(self): + """Returns empty string when record has no actionable flags.""" + device = MagicMock() + device.pk = 1 + table = self._make_table(device=device) + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/fake/"): + result = table.render_actions(None, {}) + assert result == "" + + def test_render_actions_can_install_renders_install_button(self): + """can_install=True renders an Install form button.""" + device = MagicMock() + device.pk = 1 + table = self._make_table(device=device) + record = { + "can_install": True, + "module_bay_id": 5, + "module_type_id": 10, + "serial": "SN123", + } + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/install-url/"): + result = str(table.render_actions(None, record)) + + assert "Install" in result + assert "/install-url/" in result + assert "SN123" in result + assert "mdi-download" in result + + def test_render_actions_has_installable_children_renders_branch_button(self): + """has_installable_children + ent_physical_index renders Install Branch button.""" + device = MagicMock() + device.pk = 2 + table = self._make_table(device=device) + record = { + "has_installable_children": True, + "ent_physical_index": 42, + } + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/branch-url/"): + result = str(table.render_actions(None, record)) + + assert "Install Branch" in result + assert "/branch-url/" in result + assert "mdi-file-tree" in result + + def test_render_actions_both_buttons_rendered(self): + """Both Install and Install Branch buttons render when both flags are set.""" + device = MagicMock() + device.pk = 3 + table = self._make_table(device=device) + record = { + "can_install": True, + "module_bay_id": 1, + "module_type_id": 2, + "serial": "SN-BOTH", + "has_installable_children": True, + "ent_physical_index": 99, + } + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/url/"): + result = str(table.render_actions(None, record)) + + assert "Install" in result + assert "Install Branch" in result + + def test_render_actions_installable_children_without_index_skips_branch(self): + """has_installable_children without ent_physical_index skips branch button.""" + device = MagicMock() + device.pk = 4 + table = self._make_table(device=device) + record = { + "has_installable_children": True, + # ent_physical_index intentionally absent + } + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/url/"): + result = table.render_actions(None, record) + + assert result == "" + + def test_render_actions_csrf_token_included_in_form(self): + """The CSRF token stored on the table is embedded in install form.""" + device = MagicMock() + device.pk = 5 + table = self._make_table(device=device) + table.csrf_token = "my-csrf-value" + record = {"can_install": True, "module_bay_id": 1, "module_type_id": 1, "serial": ""} + with patch("netbox_librenms_plugin.tables.modules.reverse", return_value="/url/"): + result = str(table.render_actions(None, record)) + + assert "my-csrf-value" in result + + +class TestLibreNMSModuleTableInit: + """Tests for __init__ and configure methods, bypassing django-tables2 super().__init__.""" + + def test_init_sets_device_and_defaults(self): + """__init__ sets device, tab, prefix, htmx_url, csrf_token attributes.""" + import django_tables2 as dt2 + from unittest.mock import MagicMock, patch + + device = MagicMock() + with patch.object(dt2.Table, "__init__", return_value=None): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable(device=device) + + assert table.device is device + assert table.tab == "modules" + assert table.prefix == "modules_" + assert table.htmx_url is None + assert table.csrf_token == "" + + def test_init_without_device_defaults_to_none(self): + """__init__ with no device argument sets device=None.""" + import django_tables2 as dt2 + from unittest.mock import patch + + with patch.object(dt2.Table, "__init__", return_value=None): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable() + + assert table.device is None + + def test_configure_sets_csrf_token(self): + """configure() sets csrf_token from get_token and calls RequestConfig.""" + import django_tables2 as dt2 + from unittest.mock import MagicMock, patch + + with patch.object(dt2.Table, "__init__", return_value=None): + from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable + + table = LibreNMSModuleTable() + + request = MagicMock() + mock_rc_instance = MagicMock() + + with ( + patch("netbox_librenms_plugin.tables.modules.get_table_paginate_count", return_value=25), + patch("django.middleware.csrf.get_token", return_value="csrf-abc"), + patch("django_tables2.RequestConfig", return_value=mock_rc_instance) as mock_rc_cls, + ): + table.configure(request) + + assert table.csrf_token == "csrf-abc" + mock_rc_cls.assert_called_once() + mock_rc_instance.configure.assert_called_once_with(table) diff --git a/netbox_librenms_plugin/tests/test_utils.py b/netbox_librenms_plugin/tests/test_utils.py index 96065ab760..599bec63b3 100644 --- a/netbox_librenms_plugin/tests/test_utils.py +++ b/netbox_librenms_plugin/tests/test_utils.py @@ -15,9 +15,12 @@ class TestDeviceTypeMatching: """Test device type matching logic.""" + @patch("netbox_librenms_plugin.models.DeviceTypeMapping") @patch("dcim.models.DeviceType") - def test_match_device_type_exact_match_by_part_number(self, mock_device_type): + def test_match_device_type_exact_match_by_part_number(self, mock_device_type, mock_mapping): """Exact part_number string should match.""" + mock_mapping.DoesNotExist = Exception + mock_mapping.objects.get.side_effect = mock_mapping.DoesNotExist mock_dt = MagicMock(id=1, model="C9300-48P") mock_device_type.objects.get.return_value = mock_dt @@ -29,9 +32,12 @@ def test_match_device_type_exact_match_by_part_number(self, mock_device_type): assert result["device_type"] == mock_dt assert result["match_type"] == "exact" + @patch("netbox_librenms_plugin.models.DeviceTypeMapping") @patch("dcim.models.DeviceType") - def test_match_device_type_exact_match_by_model(self, mock_device_type): + def test_match_device_type_exact_match_by_model(self, mock_device_type, mock_mapping): """Exact model string should match when part_number fails.""" + mock_mapping.DoesNotExist = Exception + mock_mapping.objects.get.side_effect = mock_mapping.DoesNotExist mock_dt = MagicMock(id=1, model="WS-C3750X-48P") # Part number lookup fails, model lookup succeeds mock_device_type.DoesNotExist = Exception @@ -48,9 +54,12 @@ def test_match_device_type_exact_match_by_model(self, mock_device_type): assert result["device_type"] == mock_dt assert result["match_type"] == "exact" + @patch("netbox_librenms_plugin.models.DeviceTypeMapping") @patch("dcim.models.DeviceType") - def test_match_device_type_not_found(self, mock_device_type): + def test_match_device_type_not_found(self, mock_device_type, mock_mapping): """Returns None when no match found.""" + mock_mapping.DoesNotExist = Exception + mock_mapping.objects.get.side_effect = mock_mapping.DoesNotExist mock_device_type.DoesNotExist = Exception mock_device_type.objects.get.side_effect = mock_device_type.DoesNotExist @@ -62,6 +71,22 @@ def test_match_device_type_not_found(self, mock_device_type): assert result["device_type"] is None assert result["match_type"] is None + @patch("netbox_librenms_plugin.models.DeviceTypeMapping") + def test_match_device_type_mapping_match(self, mock_mapping): + """DeviceTypeMapping entry should be used before part_number/model fallback.""" + mock_dt = MagicMock(id=1, model="MX480") + mock_mapping_obj = MagicMock(netbox_device_type=mock_dt) + mock_mapping.DoesNotExist = Exception + mock_mapping.objects.get.return_value = mock_mapping_obj + + from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type + + result = match_librenms_hardware_to_device_type("Juniper MX480 Internet Backbone Router") + + assert result["matched"] is True + assert result["device_type"] == mock_dt + assert result["match_type"] == "mapping" + def test_match_device_type_empty_hardware(self): """Empty string returns None.""" from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type @@ -352,6 +377,161 @@ def test_get_librenms_sync_device_with_librenms_id(self): # ============================================================================= +# ============================================================================= +# TestSetLibreNMSDeviceId - 7 tests +# ============================================================================= + + +class TestSetLibreNMSDeviceId: + """Tests for set_librenms_device_id in utils.py.""" + + def test_stores_int_for_valid_device_id(self): + """Valid integer device_id is stored under server_key.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": None} + set_librenms_device_id(obj, 42, server_key="primary") + assert obj.custom_field_data["librenms_id"] == {"primary": 42} + + def test_invalid_device_id_not_stored(self): + """Non-integer device_id is rejected and nothing is written.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {} + set_librenms_device_id(obj, "not-an-int", server_key="primary") + assert "librenms_id" not in obj.custom_field_data + + def test_invalid_device_id_does_not_overwrite_existing(self): + """Existing valid value is preserved when new device_id is invalid.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 10}} + set_librenms_device_id(obj, None, server_key="primary") + # The existing value must not be replaced with a broken one + assert obj.custom_field_data["librenms_id"] == {"primary": 10} + + def test_migrates_legacy_int_on_first_write(self): + """Legacy bare-integer value is migrated to dict format on first write.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": 7} + set_librenms_device_id(obj, 99, server_key="secondary") + assert obj.custom_field_data["librenms_id"] == {"default": 7, "secondary": 99} + + def test_adds_new_server_key_to_existing_dict(self): + """Adding a new server key preserves existing keys.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 5}} + set_librenms_device_id(obj, 20, server_key="secondary") + assert obj.custom_field_data["librenms_id"] == {"primary": 5, "secondary": 20} + + def test_string_integer_is_coerced(self): + """String '42' is coerced to int 42.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {} + set_librenms_device_id(obj, "42", server_key="primary") + assert obj.custom_field_data["librenms_id"] == {"primary": 42} + + def test_unexpected_cf_type_reset_to_empty(self): + """If custom_field_data has unexpected type for librenms_id, it is reset.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + 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} + + +# ============================================================================= +# TestSafeDisabled - tests for _safe_disabled in bulk_import.py and filters.py +# ============================================================================= + + +class TestSafeDisabledBulkImport: + """Tests for _safe_disabled in import_utils/bulk_import.py.""" + + def _call(self, val): + from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled + + return _safe_disabled({"disabled": val}) + + def test_bool_true(self): + assert self._call(True) == 1 + + def test_bool_false(self): + assert self._call(False) == 0 + + def test_string_true_lowercase(self): + assert self._call("true") == 1 + + def test_string_yes(self): + assert self._call("yes") == 1 + + def test_string_on(self): + assert self._call("on") == 1 + + def test_string_false_lowercase(self): + assert self._call("false") == 0 + + def test_string_no(self): + assert self._call("no") == 0 + + def test_string_off(self): + assert self._call("off") == 0 + + def test_numeric_one(self): + assert self._call(1) == 1 + + def test_numeric_zero(self): + assert self._call(0) == 0 + + def test_none_defaults_to_zero(self): + assert self._call(None) == 0 + + def test_missing_key_defaults_to_zero(self): + from netbox_librenms_plugin.import_utils.bulk_import import _safe_disabled + + assert _safe_disabled({}) == 0 + + def test_string_true_uppercase(self): + assert self._call("TRUE") == 1 + + +class TestSafeDisabledFilters: + """Tests for _safe_disabled in import_utils/filters.py (same contract).""" + + def _call(self, val): + from netbox_librenms_plugin.import_utils.filters import _safe_disabled + + return _safe_disabled({"disabled": val}) + + def test_bool_true(self): + assert self._call(True) == 1 + + def test_bool_false(self): + assert self._call(False) == 0 + + def test_string_true(self): + assert self._call("true") == 1 + + def test_string_no(self): + assert self._call("no") == 0 + + def test_numeric_one(self): + assert self._call(1) == 1 + + def test_none_defaults_to_zero(self): + assert self._call(None) == 0 + + class TestPaginationHelpers: """Test pagination helper functions.""" diff --git a/netbox_librenms_plugin/tests/test_view_wiring.py b/netbox_librenms_plugin/tests/test_view_wiring.py new file mode 100644 index 0000000000..339ed9a099 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_view_wiring.py @@ -0,0 +1,196 @@ +"""Step 1 smoke tests β€” verify view class wiring (mixins, MRO, key attributes). + +These tests never touch the database or network; they only inspect class +hierarchies and attribute presence. +""" + + +class TestLibreNMSAPIMixinWiring: + """Views that need LibreNMSAPIMixin must have it in their MRO.""" + + def _assert_has_api_mixin(self, view_class): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert LibreNMSAPIMixin in view_class.__mro__, f"{view_class.__name__} is missing LibreNMSAPIMixin in its MRO" + + def test_sync_interfaces_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + self._assert_has_api_mixin(SyncInterfacesView) + + def test_sync_site_location_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.locations import SyncSiteLocationView + + self._assert_has_api_mixin(SyncSiteLocationView) + + def test_add_device_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + + self._assert_has_api_mixin(AddDeviceToLibreNMSView) + + def test_update_location_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.devices import UpdateDeviceLocationView + + self._assert_has_api_mixin(UpdateDeviceLocationView) + + def test_update_device_name_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView + + self._assert_has_api_mixin(UpdateDeviceNameView) + + def test_update_device_serial_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView + + self._assert_has_api_mixin(UpdateDeviceSerialView) + + def test_update_device_type_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceTypeView + + self._assert_has_api_mixin(UpdateDeviceTypeView) + + def test_update_device_platform_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDevicePlatformView + + self._assert_has_api_mixin(UpdateDevicePlatformView) + + def test_create_assign_platform_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import CreateAndAssignPlatformView + + self._assert_has_api_mixin(CreateAndAssignPlatformView) + + def test_assign_vc_serial_has_librenms_api_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import AssignVCSerialView + + self._assert_has_api_mixin(AssignVCSerialView) + + +class TestCacheMixinWiring: + """Views that cache LibreNMS data must have CacheMixin and expose get_cache_key.""" + + def _assert_has_cache_mixin(self, view_class): + from netbox_librenms_plugin.views.mixins import CacheMixin + + assert CacheMixin in view_class.__mro__, f"{view_class.__name__} is missing CacheMixin" + assert hasattr(view_class, "get_cache_key"), f"{view_class.__name__} missing get_cache_key method" + + def test_sync_interfaces_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + self._assert_has_cache_mixin(SyncInterfacesView) + + def test_sync_cables_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.cables import SyncCablesView + + self._assert_has_cache_mixin(SyncCablesView) + + def test_sync_ip_addresses_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView + + self._assert_has_cache_mixin(SyncIPAddressesView) + + def test_sync_vlans_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView + + self._assert_has_cache_mixin(SyncVLANsView) + + def test_delete_interfaces_has_cache_mixin(self): + from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView + + self._assert_has_cache_mixin(DeleteNetBoxInterfacesView) + + +class TestPermissionMixinWiring: + """All action views must have LibreNMSPermissionMixin.""" + + def _assert_has_permission_mixin(self, view_class): + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + assert LibreNMSPermissionMixin in view_class.__mro__, ( + f"{view_class.__name__} is missing LibreNMSPermissionMixin" + ) + + def test_sync_interfaces_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + self._assert_has_permission_mixin(SyncInterfacesView) + + def test_sync_cables_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.cables import SyncCablesView + + self._assert_has_permission_mixin(SyncCablesView) + + def test_add_device_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.devices import AddDeviceToLibreNMSView + + self._assert_has_permission_mixin(AddDeviceToLibreNMSView) + + def test_remove_server_mapping_has_permission_mixin(self): + from netbox_librenms_plugin.views.sync.device_fields import RemoveServerMappingView + + self._assert_has_permission_mixin(RemoveServerMappingView) + + +class TestRequiredObjectPermissionsWiring: + """POST-only sync views that modify NetBox objects must declare required_object_permissions.""" + + def test_sync_interfaces_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + assert hasattr(SyncInterfacesView, "required_object_permissions") + + def test_sync_cables_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.cables import SyncCablesView + + assert hasattr(SyncCablesView, "required_object_permissions") + + def test_sync_vlans_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.vlans import SyncVLANsView + + assert hasattr(SyncVLANsView, "required_object_permissions") + + def test_sync_ip_addresses_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.ip_addresses import SyncIPAddressesView + + assert hasattr(SyncIPAddressesView, "required_object_permissions") + + def test_update_device_name_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceNameView + + assert hasattr(UpdateDeviceNameView, "required_object_permissions") + assert "POST" in UpdateDeviceNameView.required_object_permissions + + def test_update_device_serial_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.device_fields import UpdateDeviceSerialView + + assert hasattr(UpdateDeviceSerialView, "required_object_permissions") + + def test_delete_interfaces_has_required_object_permissions(self): + from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView + + assert hasattr(DeleteNetBoxInterfacesView, "required_object_permissions") + + +class TestViewPropertyLazyInit: + """Verify that _librenms_api starts as None (lazy, not eager-init) and that + the librenms_api property descriptor exists on the class.""" + + def test_librenms_api_mixin_property_is_defined_on_class(self): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + assert isinstance(LibreNMSAPIMixin.__dict__.get("librenms_api"), property), ( + "librenms_api must be a property descriptor on LibreNMSAPIMixin" + ) + + def test_librenms_api_starts_as_none_after_mixin_init(self): + from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin + + mixin = object.__new__(LibreNMSAPIMixin) + mixin._librenms_api = None + # The backing attribute should be None before first access + assert mixin._librenms_api is None + + def test_sync_interfaces_has_librenms_api_property_via_class(self): + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + # Check the property is accessible on the class without triggering getter + assert any("librenms_api" in vars(cls) for cls in SyncInterfacesView.__mro__) diff --git a/netbox_librenms_plugin/tests/test_vm_operations.py b/netbox_librenms_plugin/tests/test_vm_operations.py new file mode 100644 index 0000000000..e1e157a5ae --- /dev/null +++ b/netbox_librenms_plugin/tests/test_vm_operations.py @@ -0,0 +1,657 @@ +"""Tests for netbox_librenms_plugin.import_utils.vm_operations module. + +Covers create_vm_from_librenms and bulk_import_vms. +All DB interactions are mocked β€” no @pytest.mark.django_db used. +""" + +import pytest +from unittest.mock import MagicMock, patch + + +class TestCreateVmFromLibrenms: + """Tests for create_vm_from_librenms function.""" + + @pytest.fixture(autouse=True) + def _patch_atomic(self): + """transaction.atomic() is a no-op; tests mock all DB interactions.""" + from contextlib import contextmanager + + @contextmanager + def noop_atomic(): + yield + + with patch("netbox_librenms_plugin.import_utils.vm_operations.transaction.atomic", noop_atomic): + yield + + def test_success_with_computed_name(self): + """VM is created using pre-computed _computed_name when present.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = { + "device_id": 1, + "hostname": "vm01.example.com", + "_computed_name": "vm01-computed", + } + mock_cluster = MagicMock() + mock_platform = MagicMock() + validation = { + "can_import": True, + "cluster": {"cluster": mock_cluster}, + "platform": {"platform": mock_platform}, + } + mock_vm = MagicMock() + mock_vm.name = "vm01-computed" + mock_vm.pk = 10 + + with patch("virtualization.models.VirtualMachine") as mock_vm_class: + mock_vm_class.objects.create.return_value = mock_vm + result = create_vm_from_librenms(libre_device, validation) + + assert result == mock_vm + call_kwargs = mock_vm_class.objects.create.call_args[1] + assert call_kwargs["name"] == "vm01-computed" + assert call_kwargs["cluster"] == mock_cluster + assert call_kwargs["platform"] == mock_platform + + def test_fallback_to_determine_device_name_when_no_computed_name(self): + """Falls back to _determine_device_name when _computed_name is absent.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 2, "hostname": "vm02.example.com"} + validation = { + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + } + mock_vm = MagicMock() + mock_vm.name = "vm02-determined" + mock_vm.pk = 11 + + with ( + patch( + "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", + return_value="vm02-determined", + ) as mock_det, + patch("virtualization.models.VirtualMachine") as mock_vm_class, + ): + mock_vm_class.objects.create.return_value = mock_vm + result = create_vm_from_librenms(libre_device, validation) + + mock_det.assert_called_once() + call_kwargs = mock_vm_class.objects.create.call_args[1] + assert call_kwargs["name"] == "vm02-determined" + assert result == mock_vm + + def test_can_import_false_raises_value_error(self): + """Raises ValueError immediately when validation['can_import'] is False.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 3, "hostname": "vm03"} + validation = { + "can_import": False, + "issues": ["No cluster assigned", "Missing role"], + } + + with pytest.raises(ValueError, match="VM cannot be imported"): + create_vm_from_librenms(libre_device, validation) + + def test_server_key_stored_in_custom_field(self): + """librenms_id custom field uses the provided server_key via set_librenms_device_id.""" + from unittest.mock import patch + + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 5, "hostname": "vm05", "_computed_name": "vm05"} + validation = { + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + } + mock_vm = MagicMock() + mock_vm.name = "vm05" + mock_vm.pk = 50 + mock_vm.custom_field_data = {} + + with patch("virtualization.models.VirtualMachine") as mock_vm_class: + with patch("netbox_librenms_plugin.utils.set_librenms_device_id") as mock_setter: + mock_vm_class.objects.create.return_value = mock_vm + create_vm_from_librenms(libre_device, validation, server_key="secondary") + + mock_setter.assert_called_once_with(mock_vm, 5, "secondary") + mock_vm.save.assert_called_once() + + def test_role_is_passed_to_create(self): + """Optional role parameter is forwarded to VirtualMachine.objects.create.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 6, "hostname": "vm06", "_computed_name": "vm06"} + mock_role = MagicMock() + validation = { + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + } + mock_vm = MagicMock() + mock_vm.name = "vm06" + mock_vm.pk = 60 + + with patch("virtualization.models.VirtualMachine") as mock_vm_class: + mock_vm_class.objects.create.return_value = mock_vm + create_vm_from_librenms(libre_device, validation, role=mock_role) + + call_kwargs = mock_vm_class.objects.create.call_args[1] + assert call_kwargs["role"] == mock_role + + def test_platform_none_when_not_in_validation(self): + """Platform is None when validation['platform'] has no 'platform' key.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 7, "hostname": "vm07", "_computed_name": "vm07"} + validation = { + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {}, # no 'platform' key β€” .get() returns None + } + mock_vm = MagicMock() + mock_vm.name = "vm07" + mock_vm.pk = 70 + + with patch("virtualization.models.VirtualMachine") as mock_vm_class: + mock_vm_class.objects.create.return_value = mock_vm + create_vm_from_librenms(libre_device, validation) + + call_kwargs = mock_vm_class.objects.create.call_args[1] + assert call_kwargs["platform"] is None + + def test_import_comment_contains_device_id(self): + """The comments field contains a reference to LibreNMS.""" + from netbox_librenms_plugin.import_utils.vm_operations import create_vm_from_librenms + + libre_device = {"device_id": 8, "hostname": "vm08", "_computed_name": "vm08"} + validation = { + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + } + mock_vm = MagicMock() + mock_vm.name = "vm08" + mock_vm.pk = 80 + + with patch("virtualization.models.VirtualMachine") as mock_vm_class: + mock_vm_class.objects.create.return_value = mock_vm + create_vm_from_librenms(libre_device, validation) + + call_kwargs = mock_vm_class.objects.create.call_args[1] + assert "LibreNMS" in call_kwargs["comments"] + assert "netbox-librenms-plugin" in call_kwargs["comments"] + assert str(libre_device["device_id"]) in call_kwargs["comments"] + + +class TestBulkImportVms: + """Tests for bulk_import_vms function.""" + + def test_empty_vm_imports_returns_empty_result(self): + """Empty vm_imports dict returns empty success/failed/skipped lists.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"): + result = bulk_import_vms({}, mock_api, user=MagicMock()) + + assert result == {"success": [], "failed": [], "skipped": []} + + def test_permission_denied_propagates(self): + """PermissionDenied from require_permissions propagates to the caller.""" + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + with patch( + "netbox_librenms_plugin.import_utils.vm_operations.require_permissions", + side_effect=PermissionDenied("No permission"), + ): + with pytest.raises(PermissionDenied): + bulk_import_vms({1: {}}, mock_api, user=MagicMock()) + + def test_device_not_found_added_to_failed(self): + """When fetch_device_with_cache returns None, device is appended to failed.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=None, + ), + ): + result = bulk_import_vms({99: {}}, mock_api, user=MagicMock()) + + assert len(result["failed"]) == 1 + assert result["failed"][0]["device_id"] == 99 + assert "not found" in result["failed"][0]["error"].lower() + + def test_existing_device_added_to_skipped(self): + """When validation reports existing_device, device is appended to skipped.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_existing = MagicMock() + mock_existing.name = "existing-vm" + libre_device = {"device_id": 10, "hostname": "existing-vm"} + mock_validation = { + "existing_device": mock_existing, + "can_import": False, + "issues": [], + } + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ), + ): + result = bulk_import_vms({10: {}}, mock_api, user=MagicMock()) + + assert len(result["skipped"]) == 1 + assert result["skipped"][0]["device_id"] == 10 + assert "existing-vm" in result["skipped"][0]["reason"] + + def test_success_path_vm_created(self): + """Happy path: VM is created and appended to success list.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + libre_device = {"device_id": 20, "hostname": "new-vm"} + mock_validation = { + "existing_device": None, + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + "issues": [], + } + mock_vm = MagicMock() + mock_vm.name = "new-vm" + + mock_create_vm = MagicMock(return_value=mock_vm) + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", + return_value="new-vm", + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", + mock_create_vm, + ), + patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"), + patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"), + patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), + ): + result = bulk_import_vms({20: {}}, mock_api, user=MagicMock()) + + assert len(result["success"]) == 1 + assert result["success"][0]["device_id"] == 20 + assert result["success"][0]["device"] == mock_vm + assert len(result["failed"]) == 0 + assert len(result["skipped"]) == 0 + # Verify api.server_key is forwarded to create_vm_from_librenms + call_kwargs = mock_create_vm.call_args[1] + assert call_kwargs.get("server_key") == mock_api.server_key + + def test_cluster_assignment_applied(self): + """apply_cluster_to_validation is called when cluster_id is provided and found.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_cluster = MagicMock() + libre_device = {"device_id": 30, "hostname": "clustered-vm"} + mock_validation = { + "existing_device": None, + "can_import": True, + "cluster": {"cluster": mock_cluster}, + "platform": {"platform": None}, + "issues": [], + } + mock_vm = MagicMock() + mock_vm.name = "clustered-vm" + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", + return_value="clustered-vm", + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", + return_value=mock_vm, + ), + patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls, + patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster, + patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), + ): + mock_cluster_cls.objects.filter.return_value.first.return_value = mock_cluster + bulk_import_vms({30: {"cluster_id": 5}}, mock_api, user=MagicMock()) + + mock_apply_cluster.assert_called_once_with(mock_validation, mock_cluster) + + def test_role_assignment_applied(self): + """apply_role_to_validation is called when role_id is provided and found.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_role = MagicMock() + libre_device = {"device_id": 40, "hostname": "role-vm"} + mock_validation = { + "existing_device": None, + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + "issues": [], + } + mock_vm = MagicMock() + mock_vm.name = "role-vm" + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", + return_value="role-vm", + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", + return_value=mock_vm, + ), + patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster"), + patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole") as mock_role_cls, + patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation"), + patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation") as mock_apply_role, + ): + mock_role_cls.objects.filter.return_value.first.return_value = mock_role + bulk_import_vms({40: {"device_role_id": 3}}, mock_api, user=MagicMock()) + + mock_apply_role.assert_called_once_with(mock_validation, mock_role, is_vm=True) + + def test_exception_in_inner_loop_added_to_failed(self): + """Exception during VM processing is caught and added to failed list.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + side_effect=RuntimeError("Connection error"), + ), + ): + result = bulk_import_vms({50: {}}, mock_api, user=MagicMock()) + + assert len(result["failed"]) == 1 + assert result["failed"][0]["device_id"] == 50 + assert "Connection error" in result["failed"][0]["error"] + + def test_job_cancellation_breaks_loop(self): + """Loop exits early when job status is 'failed' at the 5th-iteration check.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_job = MagicMock() + mock_job.logger = MagicMock() + # Plain string: no .value attribute β†’ status_value == "failed" β†’ break + mock_job.job.status = "failed" + + # 5 VMs: cancellation check fires at idx=5 (before the 5th VM is processed) + vm_imports = {i: {} for i in range(1, 6)} + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=None, # VMs 1-4 β†’ failed; VM-5 never reached + ), + ): + result = bulk_import_vms(vm_imports, mock_api, job=mock_job) + + # VMs 1-4 added to failed; 5th cancelled before processing + assert len(result["failed"]) == 4 + + def test_job_cancellation_with_errored_status(self): + """Loop also exits for 'errored' job status.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_job = MagicMock() + mock_job.logger = MagicMock() + mock_job.job.status = "errored" + + vm_imports = {i: {} for i in range(1, 6)} + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=None, + ), + ): + result = bulk_import_vms(vm_imports, mock_api, job=mock_job) + + assert len(result["failed"]) == 4 + + def test_user_extracted_from_job_when_not_provided(self): + """User is extracted from job.job.user when the user param is None.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_user = MagicMock() + mock_job = MagicMock() + mock_job.job.user = mock_user + mock_job.logger = MagicMock() + + mock_api = MagicMock() + mock_api.server_key = "default" + + with patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions") as mock_require: + bulk_import_vms({}, mock_api, job=mock_job, user=None) + + mock_require.assert_called_once_with(mock_user, ["virtualization.add_virtualmachine"], "import VMs") + + def test_sync_options_use_sysname_and_strip_domain_forwarded(self): + """sync_options use_sysname/strip_domain are passed to validate_device_for_import.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + libre_device = {"device_id": 60, "hostname": "opts-vm"} + # existing_device set β†’ triggers skipped path (avoids more mocking) + mock_validation = { + "existing_device": MagicMock(name="opts-vm"), + "can_import": False, + "issues": [], + } + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ) as mock_validate, + ): + bulk_import_vms( + {60: {}}, + mock_api, + sync_options={"use_sysname": False, "strip_domain": True}, + user=MagicMock(), + ) + + mock_validate.assert_called_once() + call_kwargs = mock_validate.call_args[1] + assert call_kwargs["use_sysname"] is False + assert call_kwargs["strip_domain"] is True + assert call_kwargs["server_key"] == mock_api.server_key + + def test_no_cluster_id_skips_cluster_lookup(self): + """Cluster lookup is skipped when cluster_id is absent from vm_mappings.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + libre_device = {"device_id": 70, "hostname": "no-cluster-vm"} + mock_validation = { + "existing_device": None, + "can_import": True, + "cluster": {"cluster": MagicMock()}, + "platform": {"platform": None}, + "issues": [], + } + mock_vm = MagicMock() + mock_vm.name = "no-cluster-vm" + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=libre_device, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.validate_device_for_import", + return_value=mock_validation, + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations._determine_device_name", + return_value="no-cluster-vm", + ), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.create_vm_from_librenms", + return_value=mock_vm, + ), + patch("netbox_librenms_plugin.import_utils.vm_operations.Cluster") as mock_cluster_cls, + patch("netbox_librenms_plugin.import_utils.vm_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_validation_helpers.apply_cluster_to_validation") as mock_apply_cluster, + patch("netbox_librenms_plugin.import_validation_helpers.apply_role_to_validation"), + ): + # No cluster_id in vm_mappings + bulk_import_vms({70: {}}, mock_api, user=MagicMock()) + + mock_cluster_cls.objects.filter.assert_not_called() + mock_apply_cluster.assert_not_called() + + def test_job_status_value_attribute_used_when_present(self): + """Status enum .value is used for cancellation check when present.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + mock_job = MagicMock() + mock_job.logger = MagicMock() + # Status object with .value attribute (simulates Django choices enum) + mock_status = MagicMock() + mock_status.value = "failed" + mock_job.job.status = mock_status + + vm_imports = {i: {} for i in range(1, 6)} + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=None, + ), + ): + result = bulk_import_vms(vm_imports, mock_api, job=mock_job) + + assert len(result["failed"]) == 4 + + def test_job_log_info_when_not_cancelled_at_checkpoint(self): + """log.info is called at a non-cancelling 5-iteration checkpoint.""" + from netbox_librenms_plugin.import_utils.vm_operations import bulk_import_vms + + mock_api = MagicMock() + mock_api.server_key = "default" + + # Status is "running" at first checkpoint (idx=5), "failed" at second (idx=10) + statuses = iter(["running", "failed"]) + mock_job = MagicMock() + mock_job.logger = MagicMock() + mock_job.job.status = "running" + + def _refresh(): + try: + mock_job.job.status = next(statuses) + except StopIteration: + mock_job.job.status = "failed" + + mock_job.job.refresh_from_db.side_effect = _refresh + + # 10 VMs: checkpoint at idx=5 (running β†’ log.info) and idx=10 (failed β†’ break) + vm_imports = {i: {} for i in range(1, 11)} + + with ( + patch("netbox_librenms_plugin.import_utils.vm_operations.require_permissions"), + patch( + "netbox_librenms_plugin.import_utils.vm_operations.fetch_device_with_cache", + return_value=None, + ), + ): + bulk_import_vms(vm_imports, mock_api, job=mock_job) + + # log.info called at idx=5 checkpoint + mock_job.logger.info.assert_called() diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 9eafdb1dca..2d4a982602 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from .models import InterfaceTypeMapping +from .models import DeviceTypeMapping, InterfaceTypeMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule from .views import ( AddDeviceToLibreNMSView, AssignVCSerialView, @@ -14,12 +14,24 @@ DeviceInterfaceTableView, DeviceIPAddressTableView, DeviceLibreNMSSyncView, + DeviceModuleTableView, DeviceRackUpdateView, DeviceRoleUpdateView, DeviceStatusListView, + DeviceTypeMappingBulkDeleteView, + DeviceTypeMappingBulkImportView, + DeviceTypeMappingChangeLogView, + DeviceTypeMappingCreateView, + DeviceTypeMappingDeleteView, + DeviceTypeMappingEditView, + DeviceTypeMappingListView, + DeviceTypeMappingView, DeviceValidationDetailsView, DeviceVCDetailsView, DeviceVLANTableView, + InstallBranchView, + InstallModuleView, + InstallSelectedView, InterfaceTypeMappingBulkDeleteView, InterfaceTypeMappingBulkImportView, InterfaceTypeMappingChangeLogView, @@ -30,6 +42,31 @@ InterfaceTypeMappingView, LibreNMSImportView, LibreNMSSettingsView, + ModuleBayMappingBulkDeleteView, + ModuleBayMappingBulkImportView, + ModuleBayMappingChangeLogView, + ModuleBayMappingCreateView, + ModuleBayMappingDeleteView, + ModuleBayMappingEditView, + ModuleBayMappingListView, + ModuleBayMappingView, + ModuleTypeMappingBulkDeleteView, + ModuleTypeMappingBulkImportView, + ModuleTypeMappingChangeLogView, + ModuleTypeMappingCreateView, + ModuleTypeMappingDeleteView, + ModuleTypeMappingEditView, + ModuleTypeMappingListView, + ModuleTypeMappingView, + NormalizationRuleBulkDeleteView, + NormalizationRuleBulkImportView, + NormalizationRuleChangeLogView, + NormalizationRuleCreateView, + NormalizationRuleDeleteView, + NormalizationRuleEditView, + NormalizationRuleListView, + NormalizationRuleView, + RemoveServerMappingView, SaveUserPrefView, SingleCableVerifyView, SingleInterfaceVerifyView, @@ -71,6 +108,26 @@ DeviceCableTableView.as_view(), name="device_cable_sync", ), + path( + "devices//module-sync/", + DeviceModuleTableView.as_view(), + name="device_module_sync", + ), + path( + "devices//install-module/", + InstallModuleView.as_view(), + name="install_module", + ), + path( + "devices//install-branch/", + InstallBranchView.as_view(), + name="install_branch", + ), + path( + "devices//install-selected/", + InstallSelectedView.as_view(), + name="install_selected", + ), path( "devices//ipaddress-sync/", DeviceIPAddressTableView.as_view(), @@ -221,6 +278,11 @@ AssignVCSerialView.as_view(), name="assign_vc_serial", ), + path( + "devices//remove-server-mapping/", + RemoveServerMappingView.as_view(), + name="remove_server_mapping", + ), path( "device-status/", DeviceStatusListView.as_view(), @@ -335,5 +397,173 @@ InterfaceTypeMappingBulkDeleteView.as_view(), name="interfacetypemapping_bulk_delete", ), + # Device type mapping URLs + path( + "device-type-mappings/", + DeviceTypeMappingListView.as_view(), + name="devicetypemapping_list", + ), + path( + "device-type-mappings//", + DeviceTypeMappingView.as_view(), + name="devicetypemapping_detail", + ), + path( + "device-type-mappings/add/", + DeviceTypeMappingCreateView.as_view(), + name="devicetypemapping_add", + ), + path( + "device-type-mappings/import/", + DeviceTypeMappingBulkImportView.as_view(), + name="devicetypemapping_bulk_import", + ), + path( + "device-type-mappings//delete/", + DeviceTypeMappingDeleteView.as_view(), + name="devicetypemapping_delete", + ), + path( + "device-type-mappings//edit/", + DeviceTypeMappingEditView.as_view(), + name="devicetypemapping_edit", + ), + path( + "device-type-mappings//changelog/", + DeviceTypeMappingChangeLogView.as_view(), + name="devicetypemapping_changelog", + kwargs={"model": DeviceTypeMapping}, + ), + path( + "device-type-mappings/delete/", + DeviceTypeMappingBulkDeleteView.as_view(), + name="devicetypemapping_bulk_delete", + ), + # Module type mapping URLs + path( + "module-type-mappings/", + ModuleTypeMappingListView.as_view(), + name="moduletypemapping_list", + ), + path( + "module-type-mappings//", + ModuleTypeMappingView.as_view(), + name="moduletypemapping_detail", + ), + path( + "module-type-mappings/add/", + ModuleTypeMappingCreateView.as_view(), + name="moduletypemapping_add", + ), + path( + "module-type-mappings/import/", + ModuleTypeMappingBulkImportView.as_view(), + name="moduletypemapping_bulk_import", + ), + path( + "module-type-mappings//delete/", + ModuleTypeMappingDeleteView.as_view(), + name="moduletypemapping_delete", + ), + path( + "module-type-mappings//edit/", + ModuleTypeMappingEditView.as_view(), + name="moduletypemapping_edit", + ), + path( + "module-type-mappings//changelog/", + ModuleTypeMappingChangeLogView.as_view(), + name="moduletypemapping_changelog", + kwargs={"model": ModuleTypeMapping}, + ), + path( + "module-type-mappings/delete/", + ModuleTypeMappingBulkDeleteView.as_view(), + name="moduletypemapping_bulk_delete", + ), + # Module Bay Mapping URLs + path( + "module-bay-mappings/", + ModuleBayMappingListView.as_view(), + name="modulebaymapping_list", + ), + path( + "module-bay-mappings//", + ModuleBayMappingView.as_view(), + name="modulebaymapping_detail", + ), + path( + "module-bay-mappings/add/", + ModuleBayMappingCreateView.as_view(), + name="modulebaymapping_add", + ), + path( + "module-bay-mappings/import/", + ModuleBayMappingBulkImportView.as_view(), + name="modulebaymapping_bulk_import", + ), + path( + "module-bay-mappings//delete/", + ModuleBayMappingDeleteView.as_view(), + name="modulebaymapping_delete", + ), + path( + "module-bay-mappings//edit/", + ModuleBayMappingEditView.as_view(), + name="modulebaymapping_edit", + ), + path( + "module-bay-mappings//changelog/", + ModuleBayMappingChangeLogView.as_view(), + name="modulebaymapping_changelog", + kwargs={"model": ModuleBayMapping}, + ), + path( + "module-bay-mappings/delete/", + ModuleBayMappingBulkDeleteView.as_view(), + name="modulebaymapping_bulk_delete", + ), + # Normalization Rule URLs + path( + "normalization-rules/", + NormalizationRuleListView.as_view(), + name="normalizationrule_list", + ), + path( + "normalization-rules//", + NormalizationRuleView.as_view(), + name="normalizationrule_detail", + ), + path( + "normalization-rules/add/", + NormalizationRuleCreateView.as_view(), + name="normalizationrule_add", + ), + path( + "normalization-rules/import/", + NormalizationRuleBulkImportView.as_view(), + name="normalizationrule_bulk_import", + ), + path( + "normalization-rules//delete/", + NormalizationRuleDeleteView.as_view(), + name="normalizationrule_delete", + ), + path( + "normalization-rules//edit/", + NormalizationRuleEditView.as_view(), + name="normalizationrule_edit", + ), + path( + "normalization-rules//changelog/", + NormalizationRuleChangeLogView.as_view(), + name="normalizationrule_changelog", + kwargs={"model": NormalizationRule}, + ), + path( + "normalization-rules/delete/", + NormalizationRuleBulkDeleteView.as_view(), + name="normalizationrule_bulk_delete", + ), path("api/", include("netbox_librenms_plugin.api.urls")), ] diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index 4a5bf113a4..352bb0765a 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -1,13 +1,17 @@ +import logging import re from typing import Optional from dcim.models import Device +from django.db.models import Q from django.core.exceptions import ObjectDoesNotExist from django.http import HttpRequest from netbox.config import get_config from netbox.plugins import get_plugin_config from utilities.paginator import get_paginate_count as netbox_get_paginate_count +logger = logging.getLogger(__name__) + def convert_speed_to_kbps(speed_bps: int) -> int: """ @@ -193,7 +197,8 @@ def match_librenms_hardware_to_device_type(hardware_name: str) -> dict: """ Match LibreNMS hardware string to a NetBox DeviceType. - Only performs exact matching on part_number and model fields (case-insensitive). + Checks DeviceTypeMapping table first, then falls back to exact matching + on part_number and model fields (case-insensitive). Args: hardware_name (str): Hardware string from LibreNMS API (e.g., 'C9200L-48P-4X') @@ -202,13 +207,29 @@ def match_librenms_hardware_to_device_type(hardware_name: str) -> dict: dict: Dictionary containing: - matched (bool): Whether a match was found - device_type (DeviceType|None): The matched DeviceType object - - match_type (str|None): Always 'exact' if found, None otherwise + - match_type (str|None): 'mapping' if via DeviceTypeMapping, 'exact' if via + part_number/model, None otherwise """ from dcim.models import DeviceType + from netbox_librenms_plugin.models import DeviceTypeMapping + if not hardware_name or hardware_name == "-": return {"matched": False, "device_type": None, "match_type": None} + # Check DeviceTypeMapping table first + try: + mapping = DeviceTypeMapping.objects.get(librenms_hardware__iexact=hardware_name) + return { + "matched": True, + "device_type": mapping.netbox_device_type, + "match_type": "mapping", + } + except DeviceTypeMapping.DoesNotExist: + pass + except DeviceTypeMapping.MultipleObjectsReturned: + pass + # Try part number exact match try: device_type = DeviceType.objects.get(part_number__iexact=hardware_name) @@ -447,3 +468,257 @@ def check_vlan_group_matches( netbox_gid = netbox_tagged_group_ids.get(vid) return netbox_gid == selected_group_id return True + + +def get_librenms_device_id(obj, server_key: str = "default"): + """ + Get the LibreNMS device/port ID for a specific server from the JSON custom field. + + Supports both the legacy integer format and the new multi-server JSON format:: + + Legacy: librenms_id = 42 β†’ returns 42 for any server_key + New: librenms_id = {"primary": 42} β†’ returns 42 only for server_key="primary" + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + server_key: LibreNMS server key (from plugin ``servers`` config). + + Returns: + int or None + """ + cf_value = obj.cf.get("librenms_id") + if cf_value is None: + return None + if isinstance(cf_value, int): + return cf_value # backward compat: bare integer from pre-migration + if isinstance(cf_value, dict): + return cf_value.get(server_key) + return None + + +def set_librenms_device_id(obj, device_id, server_key: str = "default"): + """ + Set the LibreNMS device/port ID for a specific server on the JSON custom field. + + Migrates any legacy bare-integer value to the dict format on first write. + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + device_id: LibreNMS device ID (integer). + server_key: LibreNMS server key (from plugin ``servers`` config). + """ + cf_value = obj.custom_field_data.get("librenms_id") or {} + if isinstance(cf_value, int): + cf_value = {"default": cf_value} # migrate legacy value on first write + elif not isinstance(cf_value, dict): + logger.warning( + "librenms_id custom field has unexpected type %s on %r; resetting to empty dict.", + type(cf_value).__name__, + obj, + ) + cf_value = {} + try: + cf_value[server_key] = int(device_id) + except (TypeError, ValueError): + logger.warning( + "librenms_id device_id %r is not a valid integer on %r; not storing.", + device_id, + obj, + ) + return # Don't persist an invalid entry + obj.custom_field_data["librenms_id"] = cf_value + + +def find_by_librenms_id(model, librenms_id, server_key: str = "default"): + """ + Return the first object of *model* whose ``librenms_id`` JSON field contains + *librenms_id* under *server_key*. + + Also matches legacy records that stored ``librenms_id`` as a bare integer + directly in ``custom_field_data``. + + Args: + model: A Django model class (Device, VirtualMachine, Interface, …). + librenms_id: The LibreNMS device/port ID to look up. + server_key: LibreNMS server key (from plugin ``servers`` config). + + Returns: + Model instance or None + """ + return model.objects.filter( + Q(**{f"custom_field_data__librenms_id__{server_key}": librenms_id}) + | Q(custom_field_data__librenms_id=librenms_id) + ).first() + + +def migrate_legacy_librenms_id(obj, server_key: str = "default") -> bool: + """ + Migrate a legacy bare-integer ``librenms_id`` custom field to the JSON dict format, + scoped to *server_key*. + + Only performs the migration when the current value is a bare integer, i.e. a record + created before the multi-server JSON refactor. The integer is assumed to belong to + the server identified by *server_key* (the caller must verify this, e.g. by confirming + that the LibreNMS device ID and serial number both match). + + Does **not** call ``obj.save()`` β€” the caller is responsible for persisting the change. + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + server_key: LibreNMS server key the legacy integer should be scoped to. + + Returns: + True if the value was migrated, False if it was already in the correct format. + """ + cf_value = obj.custom_field_data.get("librenms_id") + if not isinstance(cf_value, int): + return False + obj.custom_field_data["librenms_id"] = {server_key: cf_value} + logger.info( + "Migrated legacy librenms_id %d β†’ {%r: %d} on %r", + cf_value, + server_key, + cf_value, + obj, + ) + return True + + +# Minimum NetBox version that supports {module_path} token in module templates + + +def supports_module_path(): + """Check if the running NetBox supports the {module_path} template token. + + Detects by checking for MODULE_PATH_TOKEN in dcim.constants rather than + comparing version strings β€” works with patched/pre-release builds too. + """ + try: + from dcim.constants import MODULE_PATH_TOKEN # noqa: F401 + + return True + except ImportError: + return False + + +def module_type_uses_module_path(module_type): + """Check if a ModuleType has any interface templates using {module_path}.""" + return any("{module_path}" in t.name for t in module_type.interfacetemplates.all()) + + +def module_type_uses_module_token(module_type) -> bool: + """Check if a ModuleType has interface templates using the {module} token.""" + try: + from dcim.constants import MODULE_TOKEN + except ImportError: + return False + return any(MODULE_TOKEN in t.name for t in module_type.interfacetemplates.all()) + + +def module_type_is_end_module(module_type) -> bool: + """Return True if this module type defines no module bay templates (i.e., it is a leaf/end module).""" + return not module_type.modulebaytemplates.exists() + + +def has_nested_name_conflict(module_type, module_bay): + """Check if installing this module type in a nested bay would cause a name conflict. + + Returns True when ALL of the following are true: + - The module type has interface templates using only ``{module}`` (not ``{module_path}``) + - The bay is nested (its parent is owned by an installed module) + - There is at least one sibling bay under the same parent + + In this situation NetBox's ``resolve_name()`` replaces ``{module}`` with the + root ancestor's bay position, producing the same interface name for every + sibling at this nesting level. + """ + from dcim.constants import MODULE_TOKEN + + if not module_bay or not module_bay.module_id: + return False # Top-level bay β€” no conflict + + templates = list(module_type.interfacetemplates.all()) + if not templates: + return False # No interface templates + + uses_module_token = any(MODULE_TOKEN in t.name for t in templates) + if not uses_module_token: + return False # Template doesn't use {module} + + # Count how many unique interface names this template would produce across siblings + # If all siblings resolve to the same name, there's a conflict + from dcim.models import ModuleBay as ModuleBayModel + + sibling_count = ModuleBayModel.objects.filter( + device=module_bay.device, + module_id=module_bay.module_id, + ).count() + + return sibling_count > 1 + + +def get_module_types_indexed() -> dict: + """Return all NetBox module types indexed by model (and part_number), with ModuleTypeMapping applied. + + ModuleTypeMapping entries take priority over the base model/part_number keys so that + explicit overrides win when the same string appears in both. + """ + from dcim.models import ModuleType + + from netbox_librenms_plugin.models import ModuleTypeMapping + + result: dict = {} + for mt in ModuleType.objects.all().select_related("manufacturer"): + result[mt.model] = mt + if mt.part_number and mt.part_number != mt.model: + result[mt.part_number] = mt + for mapping in ModuleTypeMapping.objects.select_related("netbox_module_type__manufacturer"): + result[mapping.librenms_model] = mapping.netbox_module_type + return result + + +def apply_normalization_rules(value: str, scope: str, manufacturer=None) -> str: + """Apply NormalizationRule chain to transform a string before matching. + + Rules for the given scope are applied in priority order. Each rule's + regex substitution transforms the output of the previous rule, forming + a pipeline. If no rules match, the original value is returned unchanged. + + When *manufacturer* is given, manufacturer-scoped rules run first, + followed by unscoped (manufacturer=NULL) rules. When *manufacturer* + is ``None``, all rules for the scope run in priority order. + + Args: + value: The raw string to normalize (e.g. '3HE16474AARA01'). + scope: One of NormalizationRule.SCOPE_* constants. + manufacturer: Optional Manufacturer instance to scope rules. + + Returns: + The normalized string after all matching rules have been applied. + """ + from netbox_librenms_plugin.models import NormalizationRule + + if not value: + return value + + if manufacturer: + # Manufacturer-specific rules first, then unscoped rules + for mfg_filter in [{"manufacturer": manufacturer}, {"manufacturer__isnull": True}]: + rules = NormalizationRule.objects.filter(scope=scope, **mfg_filter).order_by("priority", "pk") + for rule in rules: + try: + value = re.sub(rule.match_pattern, rule.replacement, value) + except re.error: + logger.error( + "Invalid regex in NormalizationRule pk=%s pattern=%r β€” skipping", rule.pk, rule.match_pattern + ) + else: + rules = NormalizationRule.objects.filter(scope=scope).order_by("priority", "pk") + for rule in rules: + try: + value = re.sub(rule.match_pattern, rule.replacement, value) + except re.error: + logger.error( + "Invalid regex in NormalizationRule pk=%s pattern=%r β€” skipping", rule.pk, rule.match_pattern + ) + return value diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index df3beac79b..cb62916c54 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -10,6 +10,7 @@ from .base.interfaces_view import BaseInterfaceTableView # noqa: F401 from .base.ip_addresses_view import BaseIPAddressTableView, SingleIPAddressVerifyView # noqa: F401 from .base.librenms_sync_view import BaseLibreNMSSyncView # noqa: F401 +from .sync.modules import InstallBranchView, InstallModuleView, InstallSelectedView # noqa: F401 from .base.vlan_table_view import BaseVLANTableView # noqa: F401 from .imports import ( # noqa: F401 BulkImportConfirmView, @@ -24,6 +25,14 @@ SaveUserPrefView, ) from .mapping_views import ( # noqa: F401 + DeviceTypeMappingBulkDeleteView, + DeviceTypeMappingBulkImportView, + DeviceTypeMappingChangeLogView, + DeviceTypeMappingCreateView, + DeviceTypeMappingDeleteView, + DeviceTypeMappingEditView, + DeviceTypeMappingListView, + DeviceTypeMappingView, InterfaceTypeMappingBulkDeleteView, InterfaceTypeMappingBulkImportView, InterfaceTypeMappingChangeLogView, @@ -32,12 +41,37 @@ InterfaceTypeMappingEditView, InterfaceTypeMappingListView, InterfaceTypeMappingView, + ModuleBayMappingBulkDeleteView, + ModuleBayMappingBulkImportView, + ModuleBayMappingChangeLogView, + ModuleBayMappingCreateView, + ModuleBayMappingDeleteView, + ModuleBayMappingEditView, + ModuleBayMappingListView, + ModuleBayMappingView, + ModuleTypeMappingBulkDeleteView, + ModuleTypeMappingBulkImportView, + ModuleTypeMappingChangeLogView, + ModuleTypeMappingCreateView, + ModuleTypeMappingDeleteView, + ModuleTypeMappingEditView, + ModuleTypeMappingListView, + ModuleTypeMappingView, + NormalizationRuleBulkDeleteView, + NormalizationRuleBulkImportView, + NormalizationRuleChangeLogView, + NormalizationRuleCreateView, + NormalizationRuleDeleteView, + NormalizationRuleEditView, + NormalizationRuleListView, + NormalizationRuleView, ) from .object_sync import ( # noqa: F401 DeviceCableTableView, DeviceInterfaceTableView, DeviceIPAddressTableView, DeviceLibreNMSSyncView, + DeviceModuleTableView, DeviceVLANTableView, SaveVlanGroupOverridesView, SingleInterfaceVerifyView, @@ -53,6 +87,7 @@ from .sync.device_fields import ( # noqa: F401 AssignVCSerialView, CreateAndAssignPlatformView, + RemoveServerMappingView, UpdateDeviceNameView, UpdateDevicePlatformView, UpdateDeviceSerialView, diff --git a/netbox_librenms_plugin/views/base/cables_view.py b/netbox_librenms_plugin/views/base/cables_view.py index c390cdd539..75f601efca 100644 --- a/netbox_librenms_plugin/views/base/cables_view.py +++ b/netbox_librenms_plugin/views/base/cables_view.py @@ -4,6 +4,7 @@ from django.contrib import messages from django.core.cache import cache from django.core.exceptions import MultipleObjectsReturned +from django.db.models import Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse @@ -17,6 +18,11 @@ from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin +def _librenms_id_q(server_key: str, value) -> Q: + """Return a combined Q matching JSON-field and legacy bare-int librenms_id.""" + return Q(**{f"custom_field_data__librenms_id__{server_key}": value}) | Q(custom_field_data__librenms_id=value) + + class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View): """ Base view for synchronizing cable information from LibreNMS. @@ -24,7 +30,6 @@ class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, model = None # To be defined in subclasses partial_template_name = "netbox_librenms_plugin/_cable_sync_content.html" - interface_name_field = get_interface_name_field() def get_object(self, pk): """Retrieve the object (Device or VirtualMachine).""" @@ -53,11 +58,17 @@ def get_links_data(self, obj): if not success or "error" in data: return None + interface_name_field = get_interface_name_field(getattr(self, "request", None)) ports_data = self.get_ports_data(obj) local_ports_map = {} for port in ports_data.get("ports", []): - port_id = str(port["port_id"]) - port_name = port[self.interface_name_field] + raw_port_id = port.get("port_id") + if raw_port_id is None: + continue + port_id = str(raw_port_id) + port_name = port.get(interface_name_field) + if port_name is None: + continue local_ports_map[port_id] = port_name links = data.get("links", []) @@ -78,10 +89,11 @@ def get_links_data(self, obj): def get_device_by_id_or_name(self, remote_device_id, hostname): """Try to find device in NetBox first by librenms_id custom field, then by name""" + server_key = self.librenms_api.server_key # First try matching by LibreNMS ID if remote_device_id: try: - device = Device.objects.get(custom_field_data__librenms_id=remote_device_id) + device = Device.objects.get(_librenms_id_q(server_key, remote_device_id)) return device, True, None except Device.DoesNotExist: pass @@ -116,13 +128,14 @@ def enrich_local_port(self, link, obj): if local_port := link.get("local_port"): interface = None local_port_id = link.get("local_port_id") + server_key = self.librenms_api.server_key if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: chassis_member = get_virtual_chassis_member(obj, local_port) # First try to find interface by librenms_id if local_port_id: - interface = chassis_member.interfaces.filter(custom_field_data__librenms_id=local_port_id).first() + interface = chassis_member.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first() # Only if librenms_id match fails, try matching by name if not interface: @@ -130,7 +143,7 @@ def enrich_local_port(self, link, obj): else: # First try to find interface by librenms_id if local_port_id: - interface = obj.interfaces.filter(custom_field_data__librenms_id=local_port_id).first() + interface = obj.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first() # Only if librenms_id match fails, try matching by name if not interface: @@ -145,6 +158,7 @@ def enrich_remote_port(self, link, device): if remote_port := link.get("remote_port"): netbox_remote_interface = None librenms_remote_port_id = link.get("remote_port_id") + server_key = self.librenms_api.server_key # Handle virtual chassis case if hasattr(device, "virtual_chassis") and device.virtual_chassis: @@ -154,7 +168,7 @@ def enrich_remote_port(self, link, device): # First try to find interface by librenms_id if librenms_remote_port_id: netbox_remote_interface = chassis_member.interfaces.filter( - custom_field_data__librenms_id=librenms_remote_port_id + _librenms_id_q(server_key, librenms_remote_port_id) ).first() # If not found by librenms_id, fall back to name matching on the correct chassis member @@ -165,7 +179,7 @@ def enrich_remote_port(self, link, device): # First try to find interface by librenms_id if librenms_remote_port_id: netbox_remote_interface = device.interfaces.filter( - custom_field_data__librenms_id=librenms_remote_port_id + _librenms_id_q(server_key, librenms_remote_port_id) ).first() # If not found by librenms_id, fall back to name matching @@ -369,9 +383,8 @@ def post(self, request): # First try to find interface by librenms_id interface = None if local_port_id := link_data.get("local_port_id"): - interface = selected_device.interfaces.filter( - custom_field_data__librenms_id=local_port_id - ).first() + _sk = self.librenms_api.server_key + interface = selected_device.interfaces.filter(_librenms_id_q(_sk, local_port_id)).first() # If not found by librenms_id, try matching by name if not interface: diff --git a/netbox_librenms_plugin/views/base/ip_addresses_view.py b/netbox_librenms_plugin/views/base/ip_addresses_view.py index 22f4b49742..85f8bef629 100644 --- a/netbox_librenms_plugin/views/base/ip_addresses_view.py +++ b/netbox_librenms_plugin/views/base/ip_addresses_view.py @@ -11,7 +11,7 @@ from virtualization.models import VirtualMachine from netbox_librenms_plugin.tables.ipaddresses import IPAddressTable -from netbox_librenms_plugin.utils import get_interface_name_field +from netbox_librenms_plugin.utils import get_interface_name_field, get_librenms_device_id from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin @@ -104,11 +104,12 @@ def _prefetch_netbox_data(self, obj): all_interfaces = list(obj.interfaces.all()) # Create maps for efficient lookups - interfaces_by_librenms_id = { - interface.custom_field_data.get("librenms_id"): interface - for interface in all_interfaces - if interface.custom_field_data.get("librenms_id") - } + server_key = self.librenms_api.server_key + interfaces_by_librenms_id = {} + for interface in all_interfaces: + lib_id = get_librenms_device_id(interface, server_key) + if lib_id is not None: + interfaces_by_librenms_id[str(lib_id)] = interface interfaces_by_name = {interface.name: interface for interface in all_interfaces} @@ -191,8 +192,8 @@ def _enrich_existing_ip(self, enriched_ip, ip_address, port_id, librenms_interfa assigned_interface = ip_address.assigned_object # Check if interface matches by LibreNMS ID - if port_id in prefetched_data["interfaces_by_librenms_id"]: - interface = prefetched_data["interfaces_by_librenms_id"][port_id] + if str(port_id) in prefetched_data["interfaces_by_librenms_id"]: + interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)] if assigned_interface == interface: enriched_ip["status"] = "matched" return @@ -207,8 +208,8 @@ def _enrich_existing_ip(self, enriched_ip, ip_address, port_id, librenms_interfa def _add_interface_info_to_ip(self, enriched_ip, port_id, librenms_interface_name, prefetched_data): """Add interface information to the IP entry regardless of IP status""" # First try to match by LibreNMS ID (highest priority) - if port_id in prefetched_data["interfaces_by_librenms_id"]: - interface = prefetched_data["interfaces_by_librenms_id"][port_id] + if str(port_id) in prefetched_data["interfaces_by_librenms_id"]: + interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)] enriched_ip["interface_name"] = interface.name enriched_ip["interface_url"] = interface.get_absolute_url() return diff --git a/netbox_librenms_plugin/views/base/librenms_sync_view.py b/netbox_librenms_plugin/views/base/librenms_sync_view.py index 1346c3cb13..61d638a271 100644 --- a/netbox_librenms_plugin/views/base/librenms_sync_view.py +++ b/netbox_librenms_plugin/views/base/librenms_sync_view.py @@ -1,5 +1,6 @@ import re +from django.conf import settings as django_settings from django.shortcuts import get_object_or_404, render from netbox.views import generic @@ -86,6 +87,7 @@ def get_context_data(self, request, obj): cable_context = self.get_cable_context(request, obj) ip_context = self.get_ip_context(request, obj) vlan_context = self.get_vlan_context(request, obj) + module_context = self.get_module_context(request, obj) interface_name_field = get_interface_name_field(request) @@ -103,6 +105,7 @@ def get_context_data(self, request, obj): "cable_sync": cable_context, "ip_sync": ip_context, "vlan_sync": vlan_context, + "module_sync": module_context, "v1v2form": AddToLIbreSNMPV1V2(prefix="v1v2"), "v3form": AddToLIbreSNMPV3(prefix="v3"), "librenms_device_id": self.librenms_id, @@ -114,11 +117,69 @@ def get_context_data(self, request, obj): "platform_info": platform_info, "vc_inventory_serials": librenms_info["librenms_device_details"].get("vc_inventory_serials", []), "manufacturers": manufacturers, + "all_server_mappings": self._build_all_server_mappings(obj, self.librenms_api.server_key), } ) return context + @staticmethod + def _build_all_server_mappings(obj, active_server_key): + """Build a list of all LibreNMS server mappings for the given device. + + Each entry describes one server<->ID mapping stored in the ``librenms_id`` + custom field: + + * ``server_key`` – the key as stored in the CF dict. + * ``display_name`` – human-readable name from PLUGINS_CONFIG, or the key. + * ``librenms_url`` – base URL of that server (``None`` when not configured). + * ``device_id`` – the integer device ID on that server. + * ``device_url`` – direct URL to the device page on that server (or ``None``). + * ``is_configured`` – True when the server key exists in current plugin config. + * ``is_active`` – True when this is the currently active server. + + Returns ``None`` for legacy bare-int format (no per-server info to show) + and ``None`` when the CF is absent/invalid. + """ + cf_value = obj.custom_field_data.get("librenms_id") + if not isinstance(cf_value, dict) or not cf_value: + return None + + plugins_cfg = getattr(django_settings, "PLUGINS_CONFIG", {}).get("netbox_librenms_plugin", {}) + servers_config = plugins_cfg.get("servers", {}) + + result = [] + for sk, did in cf_value.items(): + srv_cfg = servers_config.get(sk) + # Legacy single-server config: "default" key with no matching servers entry β€” + # fall back to root-level librenms_url/display_name in plugins_cfg. + if srv_cfg is None and sk == "default": + legacy_url = plugins_cfg.get("librenms_url") + if legacy_url: + srv_cfg = { + "librenms_url": legacy_url, + "display_name": plugins_cfg.get("display_name") or sk, + } + is_configured = srv_cfg is not None + librenms_url = srv_cfg.get("librenms_url") if srv_cfg else None + display_name = (srv_cfg.get("display_name") or sk) if srv_cfg else sk + device_url = f"{librenms_url}/device/device={did}/" if librenms_url else None + result.append( + { + "server_key": sk, + "display_name": display_name, + "librenms_url": librenms_url, + "device_id": did, + "device_url": device_url, + "is_configured": is_configured, + "is_active": sk == active_server_key, + } + ) + + # Sort: active first, then configured, then orphaned + result.sort(key=lambda e: 0 if e["is_active"] else (1 if e["is_configured"] else 2)) + return result or None + def get_librenms_device_info(self, obj): """Get the LibreNMS device information for the given object.""" found_in_librenms = False @@ -230,6 +291,7 @@ def get_librenms_device_info(self, obj): if netbox_identities & librenms_identities: mismatched_device = False else: + # Device is still found (we have librenms_id), just mismatched mismatched_device = True librenms_device_details["netbox_dns_name"] = netbox_dns_name or "-" @@ -268,6 +330,13 @@ def get_vlan_context(self, request, obj): """ return None + def get_module_context(self, request, obj): + """ + Get the context data for module sync. + Subclasses should override this method if applicable. + """ + return None + @staticmethod def _strip_vc_pattern(name): """Strip the VC member naming suffix from a device name. diff --git a/netbox_librenms_plugin/views/base/modules_view.py b/netbox_librenms_plugin/views/base/modules_view.py new file mode 100644 index 0000000000..2983d91e86 --- /dev/null +++ b/netbox_librenms_plugin/views/base/modules_view.py @@ -0,0 +1,778 @@ +from django.contrib import messages +from django.core.cache import cache +from django.shortcuts import get_object_or_404, render +from django.utils import timezone +from django.views import View + +from netbox_librenms_plugin.views.mixins import ( + CacheMixin, + LibreNMSAPIMixin, + LibreNMSPermissionMixin, +) + + +# entPhysicalClass values relevant for module sync +# Includes vendor-specific classes (Nokia TIMETRA-CHASSIS-MIB uses ioModule, cpmModule, etc.) +INVENTORY_CLASSES = { + "module", + "powerSupply", + "fan", + "port", + "container", + "ioModule", + "cpmModule", + "mdaModule", + "fabricModule", + "xioModule", +} + +# Model name values that indicate a generic/empty container (not real hardware) +_GENERIC_CONTAINER_MODELS = {"", "BUILTIN", "Default", "N/A"} + + +class BaseModuleTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View): + """ + Base view for synchronizing module/inventory data from LibreNMS. + Fetches inventory, matches against NetBox module bays and module types, + and renders a comparison table. + """ + + model = None + partial_template_name = "netbox_librenms_plugin/_module_sync_content.html" + + def get_object(self, pk): + """Retrieve the object (Device).""" + return get_object_or_404(self.model, pk=pk) + + def get_table(self, data, obj): + """Returns the table class. Subclasses should override.""" + raise NotImplementedError("Subclasses must implement get_table()") + + def post(self, request, pk): + """Fetch inventory from LibreNMS, cache it, and render the module sync table.""" + obj = self.get_object(pk) + + self.librenms_id = self.librenms_api.get_librenms_id(obj) + if not self.librenms_id: + messages.error(request, "Device not found in LibreNMS.") + return render( + request, + self.partial_template_name, + {"module_sync": {"object": obj, "table": None, "cache_expiry": None}}, + ) + + success, inventory_data = self.librenms_api.get_device_inventory(self.librenms_id) + + if not success: + messages.error(request, f"Failed to fetch inventory from LibreNMS: {inventory_data}") + return render( + request, + self.partial_template_name, + {"module_sync": {"object": obj, "table": None, "cache_expiry": None}}, + ) + + # Fetch transceiver data and merge with inventory + inventory_data = self._merge_transceiver_data(inventory_data) + + # Cache the merged inventory data, namespaced by server to avoid cross-server collisions + cache.set( + self.get_cache_key(obj, "inventory", server_key=self.librenms_api.server_key), + inventory_data, + timeout=self.librenms_api.cache_timeout, + ) + + context = self._build_context(request, obj, inventory_data) + messages.success(request, "Inventory data refreshed successfully.") + return render(request, self.partial_template_name, {"module_sync": context}) + + def get_context_data(self, request, obj): + """Get context from cache (used by the main sync view on initial page load).""" + cached_data = cache.get(self.get_cache_key(obj, "inventory", server_key=self.librenms_api.server_key)) + if not cached_data: + return {"table": None, "object": obj, "cache_expiry": None} + return self._build_context(request, obj, cached_data) + + def _build_context(self, request, obj, inventory_data): + """Build context with matched inventory items and table.""" + # Build a lookup of all inventory items by index for parent resolution + # Skip items with missing entPhysicalIndex to avoid KeyError on malformed data. + index_map = {idx: item for item in inventory_data if (idx := item.get("entPhysicalIndex")) is not None} + + # Precompute parentβ†’children map once so _get_sub_components runs in O(n) total. + children_by_parent: dict = {} + for item in inventory_data: + p = item.get("entPhysicalContainedIn") + if p is not None: + children_by_parent.setdefault(p, []).append(item) + + # Store manufacturer for normalization rules in _build_row + self._device_manufacturer = getattr(getattr(obj, "device_type", None), "manufacturer", None) + + # Preload all ModuleBayMapping rows once to avoid N+1 queries in _match_module_bay. + from netbox_librenms_plugin.models import ModuleBayMapping + + all_bay_mappings = list(ModuleBayMapping.objects.all()) + self._exact_bay_mappings = [m for m in all_bay_mappings if not m.is_regex] + self._regex_bay_mappings = [m for m in all_bay_mappings if m.is_regex] + + # Get NetBox module bays and modules for this device + device_bays, module_scoped_bays = self._get_module_bays(obj) + module_types = self._get_module_types() + + # Collect top-level items and their sub-components + # Include synthetic transceiver items (from vendors without ENTITY-MIB SFP data) + # Exclude items that have any ancestor with an INVENTORY_CLASSES class + # (they appear as sub-components under that ancestor) + top_items = [] + for item in inventory_data: + if item.get("_from_transceiver_api"): + top_items.append(item) + continue + phys_class = item.get("entPhysicalClass") + if phys_class not in INVENTORY_CLASSES: + continue + # Skip items with generic model names (not real hardware). + # Containers with empty model are physical slot representations. + model = (item.get("entPhysicalModelName") or "").strip() + if phys_class == "container" and model in _GENERIC_CONTAINER_MODELS: + continue + if model and model in _GENERIC_CONTAINER_MODELS: + continue + # Walk up ancestor chain; skip if any ancestor is an inventory-class item. + # Containers with empty model are physical slot/bay representations, not + # real modules β€” skip them so children can be top-level items. + is_descendant = False + current_idx = item.get("entPhysicalContainedIn", 0) + visited_ancestors = set() + while current_idx and current_idx in index_map and current_idx not in visited_ancestors: + visited_ancestors.add(current_idx) + ancestor = index_map[current_idx] + anc_class = ancestor.get("entPhysicalClass") + if anc_class in INVENTORY_CLASSES: + anc_model = (ancestor.get("entPhysicalModelName") or "").strip() + # Containers with generic/empty models are physical slot representations + if anc_class == "container" and anc_model in _GENERIC_CONTAINER_MODELS: + current_idx = ancestor.get("entPhysicalContainedIn", 0) + continue + is_descendant = True + break + current_idx = ancestor.get("entPhysicalContainedIn", 0) + if is_descendant: + continue + top_items.append(item) + + table_data = [] + from netbox_librenms_plugin.utils import apply_normalization_rules + + # Build combined bay lookup so synthetic transceiver entries (which may + # live inside installed modules) can find their module-scoped bays. + all_bays = dict(device_bays) + for scope_bays in module_scoped_bays.values(): + all_bays.update(scope_bays) + + for item in top_items: + # Transceiver API entries may live inside installed modules, so they + # need the full bay map. ENTITY-MIB top-level items must only match + # device-level bays to avoid name collisions with module-scoped bays + # that share the same name as a device bay. + item_bays = all_bays if item.get("_from_transceiver_api") else device_bays + row = self._build_row(item, index_map, item_bays, module_types, depth=0) + parent_row_idx = len(table_data) + table_data.append(row) + + # Determine which bays sub-components should match against: + # If parent matched a bay with an installed module, use that module's child bays. + # If parent matched a bay but it's NOT installed, children can't be installed + # individually (parent must be installed first to create child bays). + parent_module_id = None + parent_bay_matched_but_uninstalled = False + if row.get("module_bay_id"): + matched_bay = item_bays.get(row["module_bay"]) + if matched_bay and hasattr(matched_bay, "installed_module") and matched_bay.installed_module: + parent_module_id = matched_bay.installed_module.pk + else: + # Parent matched a bay but it's not installed yet + parent_bay_matched_but_uninstalled = True + + if parent_bay_matched_but_uninstalled: + # Empty dict: children can't match any bay individually + child_bays = {} + elif parent_module_id: + child_bays = module_scoped_bays.get(parent_module_id, {}) + else: + child_bays = device_bays + + # Find sub-components with a model name (transceivers, converters, etc.) + # Track bay scope per depth level so nested modules use correct bays + bays_by_depth = {0: child_bays} + parent_ent_idx = item.get("entPhysicalIndex") + if parent_ent_idx is None: + continue + sub_items = self._get_sub_components(parent_ent_idx, children_by_parent) + for depth, sub_item in sub_items: + scope_bays = bays_by_depth.get(depth, child_bays) + sub_row = self._build_row(sub_item, index_map, scope_bays, module_types, depth=depth) + table_data.append(sub_row) + + # Update bay scope for children of this sub-item. + # Must always set bays_by_depth[depth+1] when a bay was matched to + # prevent stale scope from a previously-processed sibling at the + # same depth leaking into this item's children. + if sub_row.get("module_bay_id"): + matched_sub_bay = scope_bays.get(sub_row["module_bay"]) + if ( + matched_sub_bay + and hasattr(matched_sub_bay, "installed_module") + and matched_sub_bay.installed_module + ): + sub_module_id = matched_sub_bay.installed_module.pk + bays_by_depth[depth + 1] = module_scoped_bays.get(sub_module_id, {}) + else: + # Bay matched but not yet installed: reset child scope so + # items under this uninstalled module don't accidentally + # inherit bays from a previously-processed installed sibling. + bays_by_depth[depth + 1] = {} + + # Mark parent if any child is installable + if sub_row.get("can_install"): + table_data[parent_row_idx]["has_installable_children"] = True + + # When parent is installable but children can't match bays yet + # (parent module not installed), enable "Install Branch" if any child + # has a matching module type (branch install handles bay creation). + if ( + parent_bay_matched_but_uninstalled + and row.get("can_install") + and not table_data[parent_row_idx].get("has_installable_children") + ): + for _depth, sub_item in sub_items: + sub_model = (sub_item.get("entPhysicalModelName") or "").strip() + if not sub_model: + continue + matched = module_types.get(sub_model) + if not matched: + normalized = apply_normalization_rules( + sub_model, + "module_type", + manufacturer=getattr(self, "_device_manufacturer", None), + ) + matched = module_types.get(normalized) + if matched: + table_data[parent_row_idx]["has_installable_children"] = True + break + + # Sort top-level groups by status, keeping children after their parent + table_data = self._sort_with_hierarchy(table_data) + + table = self.get_table(table_data, obj) + table.configure(request) + + cache_ttl = getattr(cache, "ttl", lambda k: None)( + self.get_cache_key(obj, "inventory", server_key=self.librenms_api.server_key) + ) + cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None else None + + return { + "table": table, + "object": obj, + "cache_expiry": cache_expiry, + "server_key": self.librenms_api.server_key, + } + + def _merge_transceiver_data(self, inventory_data): + """Merge transceiver API data with entity inventory. + + For vendors like Nokia that don't expose SFPs in ENTITY-MIB, + the transceiver API provides SFP model, serial, and type info. + + Strategy: + - For transceivers matching existing inventory items by entity_physical_index: + supplement entPhysicalModelName if empty + - For transceivers NOT in inventory: create synthetic inventory items + so they appear in the modules table + """ + success, transceivers = self.librenms_api.get_device_transceivers(self.librenms_id) + if not success or not transceivers: + return inventory_data + + # Build lookup of existing inventory items by index and serial + inv_by_index = {idx: item for item in inventory_data if (idx := item.get("entPhysicalIndex")) is not None} + inv_serials = { + (item.get("entPhysicalSerialNum") or "").strip() + for item in inventory_data + if (item.get("entPhysicalSerialNum") or "").strip() + } + + # Build port_id β†’ ifName lookup for better synthetic item naming + port_name_map = self._build_port_name_map(transceivers) + + # Types that are containers, not real transceiver modules + SKIP_TYPES = {"Port Container", "Port", ""} + + for txr in transceivers: + ent_idx = txr.get("entity_physical_index") + if not ent_idx: + continue + + model = (txr.get("model") or "").strip() + serial = (txr.get("serial") or "").strip() + txr_type = (txr.get("type") or "").strip() + + # Skip containers and entries with no useful data + if txr_type in SKIP_TYPES and not model and not serial: + continue + + # Use transceiver type as model fallback (e.g., "CFP2/QSFP28") + display_model = model or (txr_type if txr_type not in SKIP_TYPES else "") + + if ent_idx in inv_by_index: + # Supplement existing inventory item if model is missing + existing = inv_by_index[ent_idx] + if not (existing.get("entPhysicalModelName") or "").strip() and display_model: + existing["entPhysicalModelName"] = display_model + if not (existing.get("entPhysicalSerialNum") or "").strip() and serial: + existing["entPhysicalSerialNum"] = serial + else: + # Skip if serial already exists in ENTITY-MIB data (avoid duplicates) + if serial and serial in inv_serials: + continue + # Create synthetic inventory item for SFPs not in entity inventory + port_id = txr.get("port_id", 0) + ifname = port_name_map.get(port_id) + if ifname: + name = ifname + elif port_id: + name = f"Transceiver (port {port_id})" + else: + name = f"Transceiver {ent_idx}" + + synthetic = { + "entPhysicalIndex": ent_idx, + "entPhysicalName": name, + "entPhysicalClass": "port", + "entPhysicalModelName": display_model, + "entPhysicalSerialNum": serial, + "entPhysicalDescr": txr_type, + "entPhysicalContainedIn": 0, + "_from_transceiver_api": True, + } + inventory_data.append(synthetic) + # Update dedupe maps so subsequent iterations skip this entry + inv_by_index[ent_idx] = synthetic + if serial: + inv_serials.add(serial) + + return inventory_data + + def _build_port_name_map(self, transceivers): + """Build port_id β†’ ifName mapping for transceiver ports. + + Fetches port data from LibreNMS to resolve port IDs to interface names, + enabling better bay matching for synthetic transceiver items (e.g., + Nokia 1/1/c1 instead of opaque port IDs). + """ + port_ids = {txr.get("port_id") for txr in transceivers if txr.get("port_id")} + if not port_ids: + return {} + + success, ports_data = self.librenms_api.get_ports(self.librenms_id) + if not success or not isinstance(ports_data, dict): + return {} + + return { + p["port_id"]: p["ifName"] + for p in ports_data.get("ports", []) + if p.get("port_id") in port_ids and p.get("ifName") + } + + def _get_sub_components(self, parent_idx, children_by_parent): + """Find descendant items with a model name (real hardware, not empty containers). + + Returns list of (depth, item) tuples. + """ + results = [] + self._collect_descendants(parent_idx, children_by_parent, depth=1, results=results, visited={parent_idx}) + return results + + def _collect_descendants(self, parent_idx, children_by_parent, depth, results, visited=None): + """Recursively collect descendant items that have a model name.""" + if visited is None: + visited = set() + for child in children_by_parent.get(parent_idx, []): + child_idx = child.get("entPhysicalIndex") + if child_idx is None: + continue + if child_idx in visited: + continue + visited.add(child_idx) + model = (child.get("entPhysicalModelName") or "").strip() + if model and model not in _GENERIC_CONTAINER_MODELS: + results.append((depth, child)) + # Continue looking for deeper components (e.g., SFPs inside converters) + self._collect_descendants(child_idx, children_by_parent, depth + 1, results, visited) + else: + # Skip generic/empty items, but check their children + self._collect_descendants(child_idx, children_by_parent, depth, results, visited) + + def _sort_with_hierarchy(self, table_data): + """Sort table keeping children grouped under their parent.""" + status_order = {"Installed": 0, "Serial Mismatch": 1, "Matched": 2, "No Type": 3, "No Bay": 4, "Unmatched": 5} + + # Group into top-level items with their children + groups = [] + current_group = None + for row in table_data: + if row.get("depth", 0) == 0: + current_group = {"parent": row, "children": []} + groups.append(current_group) + elif current_group is not None: + current_group["children"].append(row) + + # Sort groups by parent status + groups.sort(key=lambda g: status_order.get(g["parent"]["status"], 99)) + + # Flatten back + result = [] + for group in groups: + result.append(group["parent"]) + result.extend(group["children"]) + return result + + def _get_module_bays(self, obj): + """Get module bays for the device, organized by scope. + + Returns: + tuple: (device_bays, module_bays) where: + - device_bays: {name: bay} for device-level bays (module=None) + - module_bays: {module_id: {name: bay}} for bays created by installed modules + """ + from dcim.models import ModuleBay + + bays = ModuleBay.objects.filter(device=obj).select_related("installed_module__module_type") + device_bays = {} + module_scoped_bays = {} + for bay in bays: + if bay.module_id: + module_scoped_bays.setdefault(bay.module_id, {})[bay.name] = bay + else: + device_bays[bay.name] = bay + return device_bays, module_scoped_bays + + def _get_module_types(self): + """Get all module types indexed by model/part_number, with ModuleTypeMapping applied.""" + from netbox_librenms_plugin.utils import get_module_types_indexed + + return get_module_types_indexed() + + def _find_parent_container_name(self, item, index_map): + """Resolve the nearest ancestor container name by walking up the containment chain. + + Skips ancestors with an empty entPhysicalName and continues upward until a + non-empty name is found or the chain is exhausted. + """ + contained_in = item.get("entPhysicalContainedIn", 0) + visited: set = set() + while contained_in and contained_in in index_map and contained_in not in visited: + visited.add(contained_in) + parent = index_map[contained_in] + name = parent.get("entPhysicalName", "") + if name: + return name + contained_in = parent.get("entPhysicalContainedIn", 0) + return None + + def _match_module_bay(self, item, index_map, module_bays): + """ + Try to match an inventory item to a NetBox ModuleBay. + Checks ModuleBayMapping table first (exact then regex), then falls back + to exact parent name match, then positional matching. + """ + import re + + parent_name = self._find_parent_container_name(item, index_map) + item_name = item.get("entPhysicalName", "") + item_descr = item.get("entPhysicalDescr", "") + phys_class = item.get("entPhysicalClass", "") + + # Build candidate names: parent, item name, item description + candidate_names = [n for n in [parent_name, item_name, item_descr] if n] + + # Use preloaded exact mappings (set in _build_context to avoid N+1 queries). + exact_mappings = getattr(self, "_exact_bay_mappings", None) + if exact_mappings is None: + from netbox_librenms_plugin.models import ModuleBayMapping + + exact_mappings = list(ModuleBayMapping.objects.filter(is_regex=False)) + + # Check ModuleBayMapping table for each candidate (exact match) + for name in candidate_names: + if phys_class: + mapping = next( + (m for m in exact_mappings if m.librenms_name == name and m.librenms_class == phys_class), None + ) + if not mapping: + mapping = next( + (m for m in exact_mappings if m.librenms_name == name and m.librenms_class == ""), None + ) + else: + mapping = next((m for m in exact_mappings if m.librenms_name == name and m.librenms_class == ""), None) + + if mapping and mapping.netbox_bay_name in module_bays: + return module_bays[mapping.netbox_bay_name] + + # Use preloaded regex mappings. + regex_mappings = getattr(self, "_regex_bay_mappings", None) + if regex_mappings is None: + from netbox_librenms_plugin.models import ModuleBayMapping + + regex_mappings = list(ModuleBayMapping.objects.filter(is_regex=True)) + + # Regex pattern matching on all candidate names + for name in candidate_names: + bay = self._lookup_regex_bay_mapping(re, name, phys_class, module_bays, regex_mappings) + if bay and self._fpc_slot_matches(name, bay): + return bay + + # Fallback: exact match on candidate names against bay dict + for name in candidate_names: + if name in module_bays: + return module_bays[name] + + # Positional fallback: determine slot number from container sibling order + # Handles SFPs inside converters where containers are unnamed + bay = self._match_bay_by_position(item, index_map, module_bays) + if bay: + return bay + + return None + + @staticmethod + def _fpc_slot_matches(candidate_name, bay): + """Validate that a regex-matched bay's parent slot position is consistent with + a positional descriptor like 'Model @ FPC/pic/port'. + + Returns True if the descriptor has no FPC reference, or if the bay's parent + module slot position matches the FPC number in the descriptor. Prevents + orphaned top-level items (e.g. QSFP @ 1/1/1 when FPC1 is not installed) + from incorrectly matching bays belonging to a different FPC's module. + """ + import re as _re + + match = _re.search(r"@\s+(\d+)/", candidate_name) + if not match: + return True + expected_fpc = match.group(1) + module = getattr(bay, "module", None) + if not module: + return True + parent_bay = getattr(module, "module_bay", None) + if not parent_bay: + return True + return parent_bay.position == expected_fpc + + @staticmethod + def _lookup_regex_bay_mapping(re, name, phys_class, module_bays, regex_mappings): + """Try regex ModuleBayMapping patterns against a name. + + ``regex_mappings`` is a pre-filtered list of is_regex=True ModuleBayMapping + objects (passed in from the caller to avoid per-item DB queries). + + Returns matched module bay or None. + """ + # Filter preloaded list by class (exact class match or empty-class fallback) + if phys_class: + candidates = [m for m in regex_mappings if m.librenms_class == phys_class or m.librenms_class == ""] + else: + candidates = [m for m in regex_mappings if m.librenms_class == ""] + + for mapping in candidates: + try: + match = re.fullmatch(mapping.librenms_name, name) + if match: + resolved_bay = match.expand(mapping.netbox_bay_name) + except re.error: + continue + if match: + if resolved_bay in module_bays: + bay = module_bays[resolved_bay] + if BaseModuleTableView._fpc_slot_matches(name, bay): + return bay + return None + + @staticmethod + def _match_bay_by_position(item, index_map, module_bays): + """Match bay by item's positional order among container siblings. + + When an item is inside a container (no model), walk up to find the + nearest ancestor with a model, count which container slot the item + occupies, and match to the bay by number (e.g., SFP 1, SFP 2). + """ + # Walk up through modelless containers to find the parent with a model. + # Use a visited set to detect cycles and avoid infinite loops. + current_idx = item.get("entPhysicalContainedIn", 0) + container_idx = None + visited = set() + while current_idx and current_idx in index_map and current_idx not in visited: + visited.add(current_idx) + ancestor = index_map[current_idx] + model = (ancestor.get("entPhysicalModelName") or "").strip() + if model: + # Found the parent with a model; container_idx is the intermediate container + break + container_idx = current_idx + current_idx = ancestor.get("entPhysicalContainedIn", 0) + else: + return None + + if not container_idx: + return None + + # Determine position: count siblings of the container under the parent + parent_with_model_idx = current_idx + siblings = sorted( + [i for i in index_map.values() if i.get("entPhysicalContainedIn") == parent_with_model_idx], + key=lambda x: x.get("entPhysicalParentRelPos", 0), + ) + slot_num = None + for i, sib in enumerate(siblings): + if sib["entPhysicalIndex"] == container_idx: + slot_num = i + 1 + break + + if slot_num is None: + return None + + # Try common bay naming patterns + for pattern in [f"SFP {slot_num}", f"Slot {slot_num}", f"Bay {slot_num}", f"Port {slot_num}"]: + if pattern in module_bays: + return module_bays[pattern] + + return None + + def _build_row(self, item, index_map, module_bays, module_types, depth=0): + """Build a single table row from a LibreNMS inventory item.""" + from netbox_librenms_plugin.utils import ( + apply_normalization_rules, + has_nested_name_conflict, + module_type_is_end_module, + module_type_uses_module_path, + module_type_uses_module_token, + supports_module_path, + ) + + model_name = item.get("entPhysicalModelName", "") or "" + serial = item.get("entPhysicalSerialNum", "") or "" + phys_class = item.get("entPhysicalClass", "") + name = item.get("entPhysicalName", "") or "-" + description = item.get("entPhysicalDescr", "") or "" + + # Match to NetBox module bay + matched_bay = self._match_module_bay(item, index_map, module_bays) + + # Match to NetBox module type (direct lookup, then normalization fallback) + matched_type = module_types.get(model_name) if model_name else None + if not matched_type and model_name: + normalized = apply_normalization_rules( + model_name, "module_type", manufacturer=getattr(self, "_device_manufacturer", None) + ) + if normalized != model_name: + matched_type = module_types.get(normalized) + + # Badge flags β€” purely informational, never block installation + needs_module_path = matched_type and module_type_uses_module_path(matched_type) + # {module_path} used but NetBox version does not support it β†’ "Upgrade NetBox" hint + netbox_upgrade_needed = bool(needs_module_path and not supports_module_path()) + # End module still using old {module} when {module_path} is available β†’ "Upgrade module-type" hint + suggest_type_upgrade = bool( + matched_type + and supports_module_path() + and module_type_is_end_module(matched_type) + and module_type_uses_module_token(matched_type) + ) + + # Check for nested module naming conflicts + name_conflict = matched_type and matched_bay and has_nested_name_conflict(matched_type, matched_bay) + + # Determine status + status = self._determine_status(matched_bay, matched_type, serial) + + row = { + "name": name, + "model": model_name or "-", + "serial": serial or "-", + "description": description, + "item_class": phys_class, + "module_bay": matched_bay.name if matched_bay else "-", + "module_type": matched_type.model if matched_type else "-", + "status": status, + "row_class": "", + "can_install": False, + "module_bay_id": matched_bay.pk if matched_bay else None, + "module_type_id": matched_type.pk if matched_type else None, + "depth": depth, + "ent_physical_index": item.get("entPhysicalIndex"), + "has_installable_children": False, + } + + if netbox_upgrade_needed: + row["row_class"] = "table-warning" + row["module_path_warning"] = ( + "This module type uses {module_path} in its interface templates. " + "The current NetBox version does not support {module_path} yet β€” " + "installation will proceed but interface naming may not work as expected. " + "Upgrade NetBox to enable full {module_path} support." + ) + + if suggest_type_upgrade: + row["module_type_upgrade_hint"] = ( + "This module type uses {module} in its interface templates. " + "Since this NetBox version supports {module_path}, consider updating " + "the module type's interface templates to use {module_path} for " + "precise per-slot interface naming." + ) + + if name_conflict: + row["row_class"] = "table-warning" + row["name_conflict_warning"] = ( + "This module type uses {module} in its interface template. " + "Installing multiple siblings will create duplicate interface names. " + "An interface naming plugin with a rewrite rule for this module type can resolve this." + ) + + # Add URLs for matched objects + if matched_bay: + row["module_bay_url"] = matched_bay.get_absolute_url() + # Check if a module is already installed in this bay + if hasattr(matched_bay, "installed_module") and matched_bay.installed_module: + installed = matched_bay.installed_module + row["installed_module"] = installed + row["module_url"] = installed.get_absolute_url() + # Check serial match + if serial and installed.serial and installed.serial.strip() == serial.strip(): + status = "Installed" + row["row_class"] = "table-success" + elif serial and installed.serial and installed.serial.strip() != serial.strip(): + status = "Serial Mismatch" + row["row_class"] = "table-danger" + else: + status = "Installed" + row["row_class"] = "table-success" + row["status"] = status + elif matched_type: + # Bay exists, type matched, no module installed β†’ can install + row["can_install"] = True + + if matched_type: + row["module_type_url"] = matched_type.get_absolute_url() + + return row + + def _determine_status(self, matched_bay, matched_type, serial): + """Determine the sync status for an inventory item.""" + if matched_bay and matched_type: + return "Matched" + if not matched_bay: + return "No Bay" + if not matched_type: + return "No Type" + return "Unmatched" diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index 70c9395c8f..125b9f405a 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -6,6 +6,7 @@ from django.contrib import messages from django.core.cache import cache from django.core.exceptions import PermissionDenied, ValidationError +from django.db import transaction from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, render from django.utils.html import escape @@ -30,7 +31,7 @@ fetch_model_by_id, ) from netbox_librenms_plugin.tables.device_status import DeviceImportTable -from netbox_librenms_plugin.utils import get_user_pref, save_user_pref +from netbox_librenms_plugin.utils import get_user_pref, save_user_pref, set_librenms_device_id from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin logger = logging.getLogger(__name__) @@ -60,11 +61,24 @@ def _resolve_naming_preferences(request) -> tuple[bool, bool]: settings = None - # Check POST first (form submissions), then GET (HTMX hx-include on hx-get) - if "use-sysname-toggle" in request.POST: - use_sysname = request.POST.get("use-sysname-toggle") == "on" - elif "use-sysname-toggle" in request.GET: - use_sysname = request.GET.get("use-sysname-toggle") == "on" + # Check POST first (form submissions), then GET (HTMX hx-include on hx-get). + # Support hyphenated ("use-sysname-toggle"), underscored ("use_sysname-toggle"), + # and plain canonical ("use_sysname") key variants for compatibility across + # different form/hidden-input implementations. + _USE_SYSNAME_KEYS = ("use-sysname-toggle", "use_sysname-toggle", "use_sysname") + _STRIP_DOMAIN_KEYS = ("strip-domain-toggle", "strip_domain-toggle", "strip_domain") + _TRUTHY = frozenset({"on", "true", "1"}) + + def _is_truthy(val): + return val.lower() in _TRUTHY if val is not None else False + + _use_sysname_post = next((request.POST.get(k) for k in _USE_SYSNAME_KEYS if k in request.POST), None) + _use_sysname_get = next((request.GET.get(k) for k in _USE_SYSNAME_KEYS if k in request.GET), None) + + if _use_sysname_post is not None: + use_sysname = _is_truthy(_use_sysname_post) + elif _use_sysname_get is not None: + use_sysname = _is_truthy(_use_sysname_get) else: pref = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname") if pref is not None: @@ -73,10 +87,13 @@ def _resolve_naming_preferences(request) -> tuple[bool, bool]: settings = LibreNMSSettings.objects.first() use_sysname = getattr(settings, "use_sysname_default", True) if settings else True - if "strip-domain-toggle" in request.POST: - strip_domain = request.POST.get("strip-domain-toggle") == "on" - elif "strip-domain-toggle" in request.GET: - strip_domain = request.GET.get("strip-domain-toggle") == "on" + _strip_domain_post = next((request.POST.get(k) for k in _STRIP_DOMAIN_KEYS if k in request.POST), None) + _strip_domain_get = next((request.GET.get(k) for k in _STRIP_DOMAIN_KEYS if k in request.GET), None) + + if _strip_domain_post is not None: + strip_domain = _is_truthy(_strip_domain_post) + elif _strip_domain_get is not None: + strip_domain = _is_truthy(_strip_domain_get) else: pref = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain") if pref is not None: @@ -89,6 +106,20 @@ def _resolve_naming_preferences(request) -> tuple[bool, bool]: return use_sysname, strip_domain +def _get_hostname_for_action(request, validation: dict, libre_device: dict) -> str: + """Return the resolved hostname to use when updating a device during a conflict action. + + Prefer the cached ``resolved_name`` from validation (already computed with the + user's naming prefs at validation time). Fall back to computing it fresh from + the current request's naming preferences. + """ + resolved = validation.get("resolved_name") + if resolved: + return resolved + use_sysname, strip_domain = _resolve_naming_preferences(request) + return _determine_device_name(libre_device, use_sysname=use_sysname, strip_domain=strip_domain) + + class DeviceImportHelperMixin: """Mixin providing common validation and rendering helpers for device import views.""" @@ -177,6 +208,7 @@ def get_validated_device_with_selections(self, device_id: int, request) -> tuple include_vc_detection=enable_vc, use_sysname=use_sysname, strip_domain=strip_domain, + server_key=self.librenms_api.server_key, ) validation["import_as_vm"] = is_vm @@ -323,6 +355,7 @@ def post(self, request): api=self.librenms_api, use_sysname=use_sysname, strip_domain=strip_domain, + server_key=self.librenms_api.server_key, ) # Mark validation with VC detection flag for proper URL generation in table @@ -330,7 +363,7 @@ def post(self, request): vc_requested = request.GET.get("enable_vc_detection") == "true" validation["_vc_detection_enabled"] = vc_requested - device_name = validation["resolved_name"] + device_name = validation.get("resolved_name") if validation.get("virtual_chassis", {}).get("is_stack") and device_name: validation["virtual_chassis"] = update_vc_member_suggested_names( @@ -690,6 +723,7 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit import_as_vm=is_vm, api=None, # No VC detection needed for already-imported devices include_vc_detection=False, + server_key=self.librenms_api.server_key, use_sysname=sync_options.get("use_sysname", True), strip_domain=sync_options.get("strip_domain", False), ) @@ -778,6 +812,7 @@ def get(self, request, device_id): existing = validation.get("existing_device") if existing: context["sync_info"] = self._build_sync_info(libre_device, existing) + context["existing_id_servers"] = self._build_id_server_info(existing) return render( request, @@ -812,7 +847,7 @@ def _build_sync_info(libre_device, existing_device): netbox_platform = platform_info["netbox_platform"] matching_platform = platform_info["matching_platform"] - platform_synced = librenms_os == "-" or ( + platform_synced = librenms_os == "-" or bool( netbox_platform and matching_platform and netbox_platform.pk == matching_platform.pk ) @@ -843,6 +878,29 @@ def _build_sync_info(libre_device, existing_device): "all_synced": all_synced, } + @staticmethod + def _build_id_server_info(existing_device): + """Return per-server ID mappings for the existing device's librenms_id custom field. + + Returns a list of dicts with server_key, display_name, and device_id β€” one entry + per server the device is linked to. Returns None when the format is legacy (bare int) + or when the field is absent/invalid. + """ + from django.conf import settings + + cf_value = existing_device.custom_field_data.get("librenms_id") + if not isinstance(cf_value, dict): + return None + + plugins_config = settings.PLUGINS_CONFIG.get("netbox_librenms_plugin", {}) + servers_config = plugins_config.get("servers", {}) + result = [] + for sk, did in cf_value.items(): + srv_cfg = servers_config.get(sk, {}) + display_name = srv_cfg.get("display_name") or sk + result.append({"server_key": sk, "display_name": display_name, "device_id": did}) + return result or None + class DeviceRoleUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a role is selected.""" @@ -938,108 +996,125 @@ def post(self, request, device_id): librenms_device_type = validation.get("device_type", {}).get("device_type") librenms_id = libre_device.get("device_id") - - # Check for LibreNMS ID collision before any linking action + try: + librenms_id = int(librenms_id) + except (TypeError, ValueError): + return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400) + + # Wrap the LibreNMS-ID collision check and subsequent write in a single + # transaction so the read-then-write is atomic for link/update/update_serial. + # NOTE: A fully race-free guarantee would require a DB-unique constraint on + # (server_key, librenms_id) β€” e.g., a dedicated DeviceLibreNMSIDMapping model. + # That is deferred to a future schema migration. Until then, we acquire a + # row-level lock on the target device before re-checking for conflicts, which + # serializes concurrent operations on the SAME device and greatly reduces the + # window for assigning the same ID to two DIFFERENT devices. if action in {"link", "update", "update_serial"}: - id_conflict = ( - Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)) - .exclude(pk=existing_device.pk) - .first() - ) - if id_conflict: - return HttpResponse( - f"LibreNMS ID conflict: ID {escape(str(librenms_id))} is already assigned to device " - f"'{escape(id_conflict.name)}' (ID: {id_conflict.pk})", - status=409, - ) - - if action == "link": - # Link to LibreNMS and update name from LibreNMS data - resolved_name = validation.get("resolved_name") - hostname = ( - resolved_name - if resolved_name - else _determine_device_name( - libre_device, - use_sysname=request.POST.get("use-sysname-toggle") == "on", - strip_domain=request.POST.get("strip-domain-toggle") == "on", - ) - ) - existing_device.custom_field_data["librenms_id"] = int(librenms_id) - existing_device.name = hostname - if librenms_device_type: - existing_device.device_type = librenms_device_type - if err := _save_device(existing_device): - return err - logger.info(f"Linked device '{existing_device.name}' to LibreNMS ID {librenms_id}") - - elif action == "update": - # Update hostname, serial, and link to LibreNMS - resolved_name = validation.get("resolved_name") - incoming_serial = libre_device.get("serial") or "" - hostname = ( - resolved_name - if resolved_name - else _determine_device_name( - libre_device, - use_sysname=request.POST.get("use-sysname-toggle") == "on", - strip_domain=request.POST.get("strip-domain-toggle") == "on", - ) - ) - existing_device.custom_field_data["librenms_id"] = int(librenms_id) - if incoming_serial and incoming_serial != "-": - conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first() - if conflict_device: + from django.db.models import Q + + with transaction.atomic(): + server_key = self.librenms_api.server_key + # Lock the target device row so concurrent requests for the same + # device are serialized. The conflict check below is still a + # best-effort guard for different devices; a DB unique constraint + # would be needed for full protection. + try: + existing_device = Device.objects.select_for_update().get(pk=existing_device.pk) + except Device.DoesNotExist: return HttpResponse( - f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " - f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", + "Device no longer exists; it may have been deleted concurrently.", status=409, ) - existing_device.serial = incoming_serial - existing_device.name = hostname - if librenms_device_type: - existing_device.device_type = librenms_device_type - if err := _save_device(existing_device): - return err - logger.info( - f"Updated device '{existing_device.name}': serial={incoming_serial}, " - f"linked to LibreNMS ID {librenms_id}" - ) - - elif action == "update_serial": - # Update only the serial and link to LibreNMS - incoming_serial = libre_device.get("serial") or "" - existing_device.custom_field_data["librenms_id"] = int(librenms_id) - if incoming_serial and incoming_serial != "-": - conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first() - if conflict_device: + conflict_exists = ( + Device.objects.filter( + Q(**{f"custom_field_data__librenms_id__{server_key}": librenms_id}) + | Q(custom_field_data__librenms_id=librenms_id) + ) + .exclude(pk=existing_device.pk) + .exists() + ) + if conflict_exists: return HttpResponse( - f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " - f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", + f"LibreNMS ID conflict: ID {librenms_id} is already assigned to another device.", status=409, ) - existing_device.serial = incoming_serial - if librenms_device_type: - existing_device.device_type = librenms_device_type - if err := _save_device(existing_device): - return err - logger.info( - f"Updated serial on device '{existing_device.name}' to {incoming_serial}, " - f"linked to LibreNMS ID {librenms_id}" - ) + + if action == "link": + # Link to LibreNMS and update name from LibreNMS data + hostname = _get_hostname_for_action(request, validation, libre_device) + set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) + existing_device.name = hostname + if librenms_device_type: + existing_device.device_type = librenms_device_type + if err := _save_device(existing_device): + return err + logger.info(f"Linked device '{existing_device.name}' to LibreNMS ID {librenms_id}") + + elif action == "update": + # Update hostname, serial, and link to LibreNMS + hostname = _get_hostname_for_action(request, validation, libre_device) + incoming_serial = libre_device.get("serial") or "" + if incoming_serial and incoming_serial != "-": + # Lock any conflicting device under the same transaction to reduce + # the serial-assignment race window (best-effort; a DB unique + # constraint on serial would give full protection). + conflict_device = ( + Device.objects.select_for_update() + .filter(serial=incoming_serial) + .exclude(pk=existing_device.pk) + .first() + ) + if conflict_device: + return HttpResponse( + f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " + f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", + status=409, + ) + existing_device.serial = incoming_serial + existing_device.name = hostname + if librenms_device_type: + existing_device.device_type = librenms_device_type + set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) + if err := _save_device(existing_device): + return err + logger.info( + f"Updated device '{existing_device.name}': serial={incoming_serial}, " + f"linked to LibreNMS ID {librenms_id}" + ) + + elif action == "update_serial": + # Update only the serial and link to LibreNMS + incoming_serial = libre_device.get("serial") or "" + if incoming_serial and incoming_serial != "-": + # Lock any conflicting device under the same transaction to reduce + # the serial-assignment race window (best-effort; a DB unique + # constraint on serial would give full protection). + conflict_device = ( + Device.objects.select_for_update() + .filter(serial=incoming_serial) + .exclude(pk=existing_device.pk) + .first() + ) + if conflict_device: + return HttpResponse( + f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " + f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", + status=409, + ) + existing_device.serial = incoming_serial + if librenms_device_type: + existing_device.device_type = librenms_device_type + set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) + if err := _save_device(existing_device): + return err + logger.info( + f"Updated serial on device '{existing_device.name}' to {incoming_serial}, " + f"linked to LibreNMS ID {librenms_id}" + ) elif action == "sync_name": # Sync device name from LibreNMS (e.g., IP β†’ sysName) - resolved_name = validation.get("resolved_name") - hostname = ( - resolved_name - if resolved_name - else _determine_device_name( - libre_device, - use_sysname=request.POST.get("use-sysname-toggle") == "on", - strip_domain=request.POST.get("strip-domain-toggle") == "on", - ) - ) + hostname = _get_hostname_for_action(request, validation, libre_device) existing_device.name = hostname if err := _save_device(existing_device): return err @@ -1056,25 +1131,41 @@ def post(self, request, device_id): return HttpResponse("No LibreNMS device type available to update", status=400) elif action == "sync_serial": - # Sync serial number from LibreNMS + # Sync serial number from LibreNMS. + # Wrap conflict-check-and-write in a transaction with a row lock so + # concurrent requests cannot both pass the serial uniqueness guard. incoming_serial = libre_device.get("serial") or "" if incoming_serial and incoming_serial != "-": - # Check for serial ownership conflict - conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first() - if conflict_device: - logger.warning( - f"Serial sync blocked: '{incoming_serial}' already assigned to " - f"'{conflict_device.name}' (pk={conflict_device.pk})" - ) - return HttpResponse( - f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " - f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", - status=409, - ) - existing_device.serial = incoming_serial - if err := _save_device(existing_device): - return err - logger.info(f"Synced serial on '{existing_device.name}' to {incoming_serial}") + with transaction.atomic(): + try: + locked_device = Device.objects.select_for_update().get(pk=existing_device.pk) + except Device.DoesNotExist: + return HttpResponse( + "Device no longer exists; it may have been deleted concurrently.", + status=409, + ) + # Re-check for serial ownership conflict under lock. + # Note: We intentionally do NOT enforce a DB-level uniqueness constraint on + # Device.serial. During device moves/replacements, multiple devices may + # temporarily share a serial (old record gets updated later). A unique + # constraint would block those valid workflows. Instead, we rely on this + # in-transaction row-lock check to guard concurrent sync of the SAME serial, + # and flag conflicts via a 409 response for the user to resolve manually. + conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first() + if conflict_device: + logger.warning( + f"Serial sync blocked: '{incoming_serial}' already assigned to " + f"'{conflict_device.name}' (pk={conflict_device.pk})" + ) + return HttpResponse( + f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device " + f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})", + status=409, + ) + locked_device.serial = incoming_serial + if err := _save_device(locked_device): + return err + logger.info(f"Synced serial on '{locked_device.name}' to {incoming_serial}") else: return HttpResponse("No valid serial from LibreNMS", status=400) @@ -1109,6 +1200,78 @@ def post(self, request, device_id): else: return HttpResponse(f"No matching device type for '{escape(hardware)}'", status=400) + elif action == "migrate_librenms_id": + # Migrate legacy bare-integer librenms_id to the JSON dict format. + # Only safe when the integer matches the LibreNMS device ID for this server, + # confirmed by serial match (or explicit force). + from netbox_librenms_plugin.utils import migrate_legacy_librenms_id + + # Direct access needed to detect legacy integer format for migration prompt: + # LibreNMSAPI.get_librenms_id() returns an int in both formats; only the raw + # type check on custom_field_data reveals whether migration is needed. + cf_value = existing_device.custom_field_data.get("librenms_id") + if not isinstance(cf_value, int): + return HttpResponse( + "Device librenms_id is already in JSON format; no migration needed.", + status=400, + ) + # Verify the stored legacy ID matches the active LibreNMS device_id so we don't + # migrate a stale/incorrect association to the wrong server mapping. + if cf_value != librenms_id: + return HttpResponse( + f"Legacy librenms_id ({cf_value}) does not match the active device ID " + f"({librenms_id}); cannot migrate safely.", + status=400, + ) + if not validation.get("serial_confirmed") and not force: + return HttpResponse( + "Serial number not confirmed. Check the force checkbox to migrate without serial verification.", + status=400, + ) + with transaction.atomic(): + try: + locked_device = Device.objects.select_for_update().get(pk=existing_device.pk) + except Device.DoesNotExist: + return HttpResponse( + "Device no longer exists; it may have been deleted concurrently.", + status=409, + ) + # Re-check under lock β€” another request may have already migrated it + cf_locked = locked_device.custom_field_data.get("librenms_id") + if not isinstance(cf_locked, int): + return HttpResponse( + "Device librenms_id is already in JSON format; no migration needed.", + status=400, + ) + if cf_locked != librenms_id: + return HttpResponse( + f"Legacy librenms_id changed under lock ({cf_locked} != {librenms_id}); cannot migrate safely.", + status=400, + ) + # Check that no other device already owns this ID on this server + # (both new namespaced format and legacy integer format) + server_key = self.librenms_api.server_key + conflict = ( + Device.objects.filter( + Q(**{f"custom_field_data__librenms_id__{server_key}": cf_locked}) + | Q(custom_field_data__librenms_id=cf_locked) + ) + .exclude(pk=locked_device.pk) + .exists() + ) + if conflict: + return HttpResponse( + f"Another device already has librenms_id {cf_locked} for server '{server_key}'; cannot migrate.", + status=409, + ) + migrate_legacy_librenms_id(locked_device, self.librenms_api.server_key) + if err := _save_device(locked_device): + return err + logger.info( + f"Migrated legacy librenms_id on '{existing_device.name}' " + f"to {{{self.librenms_api.server_key!r}: {cf_value}}}" + ) + else: return HttpResponse(f"Unknown action: {escape(action)}", status=400) diff --git a/netbox_librenms_plugin/views/imports/list.py b/netbox_librenms_plugin/views/imports/list.py index e0468f5abd..e077b6de58 100644 --- a/netbox_librenms_plugin/views/imports/list.py +++ b/netbox_librenms_plugin/views/imports/list.py @@ -92,6 +92,8 @@ def _load_job_results(self, job_id): filters = job_data.get("filters", {}) server_key = job_data.get("server_key", "default") vc_enabled = job_data.get("vc_detection_enabled", False) + use_sysname = job_data.get("use_sysname", True) + strip_domain = job_data.get("strip_domain", False) # Extract cache metadata for frontend warnings self._cache_timestamp = job_data.get("cached_at") @@ -109,6 +111,8 @@ def _load_job_results(self, job_id): filters=filters, device_id=device_id, vc_enabled=vc_enabled, + use_sysname=use_sysname, + strip_domain=strip_domain, ) device = cache.get(cache_key) if device: @@ -259,9 +263,9 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc return_cache_status=True, ) devices_cached = devices_from_cache - except Exception: + except Exception as e: # Cache check failed; proceed with background job decision based on device_count - pass + logger.debug("Cache check failed; proceeding without cached result: %s", e, exc_info=True) # Get device count for background job decision try: @@ -275,6 +279,30 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc logger.error(f"Error getting device count: {e}") device_count = 0 + # Load settings for background job decision; resolve naming preferences. + # We intentionally read user_pref here rather than request.GET because the + # naming toggles (use-sysname-toggle, strip-domain-toggle) live OUTSIDE the + # filter form (method="get") and are not submitted with it. Instead, each + # toggle fires a savePref() AJAX call on change, so the user_pref is always + # up-to-date by the time the filter form is submitted. + settings = None + try: + settings = LibreNMSSettings.objects.first() + except Exception: + logger.exception("Failed to load LibreNMSSettings for background job naming prefs") + _use_sysname_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname") + _strip_domain_pref = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain") + _use_sysname = ( + _use_sysname_pref + if _use_sysname_pref is not None + else (getattr(settings, "use_sysname_default", True) if settings else True) + ) + _strip_domain = ( + _strip_domain_pref + if _strip_domain_pref is not None + else (getattr(settings, "strip_domain_default", False) if settings else False) + ) + # Decide whether to use background job # Skip background job if data is already cached if not devices_cached and self.should_use_background_job(): @@ -291,8 +319,8 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc show_disabled=bool(self._filter_form_data.get("show_disabled")), exclude_existing=bool(self._filter_form_data.get("exclude_existing")), server_key=self.librenms_api.server_key, - use_sysname=self._use_sysname, - strip_domain=self._strip_domain, + use_sysname=_use_sysname, + strip_domain=_strip_domain, ) logger.info( @@ -324,6 +352,26 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc filter_warning = self._filter_warning + # Load settings for import defaults + try: + settings, _ = LibreNMSSettings.objects.get_or_create() + except Exception: + _user = getattr(request, "user", None) + logger.exception( + "Failed to get or create LibreNMSSettings during LibreNMS import for user %s", + getattr(_user, "username", str(_user)), + ) + settings = None + + # User preference overrides for toggles (persisted per-user) + use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname") + strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain") + # Fall back to server-level settings + if use_sysname is None: + use_sysname = getattr(settings, "use_sysname_default", True) if settings else True + if strip_domain is None: + strip_domain = getattr(settings, "strip_domain_default", False) if settings else False + # Get active cached searches for this server cached_searches = get_active_cached_searches(self.librenms_api.server_key) @@ -416,6 +464,32 @@ def _get_import_queryset(self): show_disabled = bool(data_source.get("show_disabled")) exclude_existing = bool(data_source.get("exclude_existing")) + # Resolve naming preferences: submitted form toggle β†’ user pref β†’ settings default. + # When pref is None (first-time user) any explicit toggle in data_source should still win. + use_sysname_pref = get_user_pref(self._request, "plugins.netbox_librenms_plugin.use_sysname") + strip_domain_pref = get_user_pref(self._request, "plugins.netbox_librenms_plugin.strip_domain") + try: + _settings = LibreNMSSettings.objects.first() + except Exception: + logger.exception("Failed to load LibreNMSSettings for naming preferences") + _settings = None + _use_sysname_toggle = data_source.get("use_sysname_toggle") + use_sysname = ( + _use_sysname_toggle + if _use_sysname_toggle is not None + else use_sysname_pref + if use_sysname_pref is not None + else (getattr(_settings, "use_sysname_default", True) if _settings else True) + ) + _strip_domain_toggle = data_source.get("strip_domain_toggle") + strip_domain = ( + _strip_domain_toggle + if _strip_domain_toggle is not None + else strip_domain_pref + if strip_domain_pref is not None + else (getattr(_settings, "strip_domain_default", False) if _settings else False) + ) + validated_devices, from_cache = process_device_filters( api=self.librenms_api, filters=libre_filters, @@ -425,8 +499,8 @@ def _get_import_queryset(self): exclude_existing=exclude_existing, request=self._request, return_cache_status=True, - use_sysname=self._use_sysname, - strip_domain=self._strip_domain, + use_sysname=use_sysname, + strip_domain=strip_domain, ) self._from_cache = from_cache @@ -440,6 +514,8 @@ def _get_import_queryset(self): server_key=self.librenms_api.server_key, filters=libre_filters, vc_enabled=vc_detection_enabled, + use_sysname=use_sysname, + strip_domain=strip_domain, ) cache_metadata = cache.get(cache_metadata_key) if cache_metadata: diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index b1fcec9c77..55ff7bd658 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -1,14 +1,44 @@ from netbox.views import generic from utilities.views import register_model_view -from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet +from netbox_librenms_plugin.filters import ( + DeviceTypeMappingFilterSet, + InterfaceTypeMappingFilterSet, + ModuleBayMappingFilterSet, + ModuleTypeMappingFilterSet, + NormalizationRuleFilterSet, +) from netbox_librenms_plugin.forms import ( + DeviceTypeMappingFilterForm, + DeviceTypeMappingForm, + DeviceTypeMappingImportForm, InterfaceTypeMappingFilterForm, InterfaceTypeMappingForm, InterfaceTypeMappingImportForm, + ModuleBayMappingFilterForm, + ModuleBayMappingForm, + ModuleBayMappingImportForm, + ModuleTypeMappingFilterForm, + ModuleTypeMappingForm, + ModuleTypeMappingImportForm, + NormalizationRuleFilterForm, + NormalizationRuleForm, + NormalizationRuleImportForm, +) +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) +from netbox_librenms_plugin.tables.mappings import ( + DeviceTypeMappingTable, + InterfaceTypeMappingTable, + ModuleBayMappingTable, + ModuleTypeMappingTable, + NormalizationRuleTable, ) -from netbox_librenms_plugin.models import InterfaceTypeMapping -from netbox_librenms_plugin.tables.mappings import InterfaceTypeMappingTable from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin @@ -84,3 +114,243 @@ class InterfaceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectC """ queryset = InterfaceTypeMapping.objects.all() + + +# --- DeviceTypeMapping views --- + + +class DeviceTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all DeviceTypeMapping objects.""" + + queryset = DeviceTypeMapping.objects.all() + table = DeviceTypeMappingTable + filterset = DeviceTypeMappingFilterSet + filterset_form = DeviceTypeMappingFilterForm + template_name = "netbox_librenms_plugin/devicetypemapping_list.html" + + +class DeviceTypeMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new DeviceTypeMapping object.""" + + queryset = DeviceTypeMapping.objects.all() + form = DeviceTypeMappingForm + + +@register_model_view(DeviceTypeMapping, "bulk_import", path="import", detail=False) +class DeviceTypeMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing DeviceTypeMapping objects.""" + + queryset = DeviceTypeMapping.objects.all() + model_form = DeviceTypeMappingImportForm + + +class DeviceTypeMappingView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific DeviceTypeMapping object.""" + + queryset = DeviceTypeMapping.objects.all() + + +class DeviceTypeMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific DeviceTypeMapping object.""" + + queryset = DeviceTypeMapping.objects.all() + form = DeviceTypeMappingForm + + +class DeviceTypeMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific DeviceTypeMapping object.""" + + queryset = DeviceTypeMapping.objects.all() + + +class DeviceTypeMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple DeviceTypeMapping objects.""" + + queryset = DeviceTypeMapping.objects.all() + table = DeviceTypeMappingTable + + +class DeviceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific DeviceTypeMapping object.""" + + queryset = DeviceTypeMapping.objects.all() + + +# --- ModuleTypeMapping views --- + + +class ModuleTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all ModuleTypeMapping objects.""" + + queryset = ModuleTypeMapping.objects.all() + table = ModuleTypeMappingTable + filterset = ModuleTypeMappingFilterSet + filterset_form = ModuleTypeMappingFilterForm + template_name = "netbox_librenms_plugin/moduletypemapping_list.html" + + +class ModuleTypeMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new ModuleTypeMapping object.""" + + queryset = ModuleTypeMapping.objects.all() + form = ModuleTypeMappingForm + + +@register_model_view(ModuleTypeMapping, "bulk_import", path="import", detail=False) +class ModuleTypeMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing ModuleTypeMapping objects.""" + + queryset = ModuleTypeMapping.objects.all() + model_form = ModuleTypeMappingImportForm + + +class ModuleTypeMappingView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific ModuleTypeMapping object.""" + + queryset = ModuleTypeMapping.objects.all() + + +class ModuleTypeMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific ModuleTypeMapping object.""" + + queryset = ModuleTypeMapping.objects.all() + form = ModuleTypeMappingForm + + +class ModuleTypeMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific ModuleTypeMapping object.""" + + queryset = ModuleTypeMapping.objects.all() + + +class ModuleTypeMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple ModuleTypeMapping objects.""" + + queryset = ModuleTypeMapping.objects.all() + table = ModuleTypeMappingTable + + +class ModuleTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific ModuleTypeMapping object.""" + + queryset = ModuleTypeMapping.objects.all() + + +# --- ModuleBayMapping views --- + + +class ModuleBayMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all ModuleBayMapping objects.""" + + queryset = ModuleBayMapping.objects.all() + table = ModuleBayMappingTable + filterset = ModuleBayMappingFilterSet + filterset_form = ModuleBayMappingFilterForm + template_name = "netbox_librenms_plugin/modulebaymapping_list.html" + + +class ModuleBayMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new ModuleBayMapping object.""" + + queryset = ModuleBayMapping.objects.all() + form = ModuleBayMappingForm + + +@register_model_view(ModuleBayMapping, "bulk_import", path="import", detail=False) +class ModuleBayMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing ModuleBayMapping objects.""" + + queryset = ModuleBayMapping.objects.all() + model_form = ModuleBayMappingImportForm + + +class ModuleBayMappingView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific ModuleBayMapping object.""" + + queryset = ModuleBayMapping.objects.all() + + +class ModuleBayMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific ModuleBayMapping object.""" + + queryset = ModuleBayMapping.objects.all() + form = ModuleBayMappingForm + + +class ModuleBayMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific ModuleBayMapping object.""" + + queryset = ModuleBayMapping.objects.all() + + +class ModuleBayMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple ModuleBayMapping objects.""" + + queryset = ModuleBayMapping.objects.all() + table = ModuleBayMappingTable + + +class ModuleBayMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific ModuleBayMapping object.""" + + queryset = ModuleBayMapping.objects.all() + + +# --- NormalizationRule views --- + + +class NormalizationRuleListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all NormalizationRule objects.""" + + queryset = NormalizationRule.objects.all() + table = NormalizationRuleTable + filterset = NormalizationRuleFilterSet + filterset_form = NormalizationRuleFilterForm + template_name = "netbox_librenms_plugin/normalizationrule_list.html" + + +class NormalizationRuleCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new NormalizationRule object.""" + + queryset = NormalizationRule.objects.all() + form = NormalizationRuleForm + + +@register_model_view(NormalizationRule, "bulk_import", path="import", detail=False) +class NormalizationRuleBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing NormalizationRule objects.""" + + queryset = NormalizationRule.objects.all() + model_form = NormalizationRuleImportForm + + +class NormalizationRuleView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific NormalizationRule object.""" + + queryset = NormalizationRule.objects.all() + + +class NormalizationRuleEditView(LibreNMSPermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific NormalizationRule object.""" + + queryset = NormalizationRule.objects.all() + form = NormalizationRuleForm + + +class NormalizationRuleDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific NormalizationRule object.""" + + queryset = NormalizationRule.objects.all() + + +class NormalizationRuleBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple NormalizationRule objects.""" + + queryset = NormalizationRule.objects.all() + table = NormalizationRuleTable + + +class NormalizationRuleChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific NormalizationRule object.""" + + queryset = NormalizationRule.objects.all() diff --git a/netbox_librenms_plugin/views/mixins.py b/netbox_librenms_plugin/views/mixins.py index 73513f88df..ef596b82fd 100644 --- a/netbox_librenms_plugin/views/mixins.py +++ b/netbox_librenms_plugin/views/mixins.py @@ -287,16 +287,20 @@ class CacheMixin: A mixin class that provides caching functionality. """ - def get_cache_key(self, obj, data_type="ports"): + def get_cache_key(self, obj, data_type="ports", server_key=None): """ Get the cache key for the object. Args: obj: The object to cache data for - data_type: Type of data being cached ('ports' or 'links') + data_type: Type of data being cached ('ports', 'links', 'inventory', etc.) + server_key: Optional LibreNMS server key for namespacing per-server data """ model_name = obj._meta.model_name - return f"librenms_{data_type}_{model_name}_{obj.pk}" + base = f"librenms_{data_type}_{model_name}_{obj.pk}" + if server_key: + return f"{base}_{server_key}" + return base def get_last_fetched_key(self, obj, data_type="ports"): """ diff --git a/netbox_librenms_plugin/views/object_sync/__init__.py b/netbox_librenms_plugin/views/object_sync/__init__.py index e9893cf7d8..f025cb2a7b 100644 --- a/netbox_librenms_plugin/views/object_sync/__init__.py +++ b/netbox_librenms_plugin/views/object_sync/__init__.py @@ -5,6 +5,7 @@ DeviceInterfaceTableView, DeviceIPAddressTableView, DeviceLibreNMSSyncView, + DeviceModuleTableView, DeviceVLANTableView, SaveVlanGroupOverridesView, SingleInterfaceVerifyView, diff --git a/netbox_librenms_plugin/views/object_sync/devices.py b/netbox_librenms_plugin/views/object_sync/devices.py index 97584f8319..3c3cd2bfc8 100644 --- a/netbox_librenms_plugin/views/object_sync/devices.py +++ b/netbox_librenms_plugin/views/object_sync/devices.py @@ -17,6 +17,7 @@ LibreNMSInterfaceTable, VCInterfaceTable, ) +from netbox_librenms_plugin.tables.modules import LibreNMSModuleTable from netbox_librenms_plugin.utils import ( get_interface_name_field, get_missing_vlan_warning, @@ -29,6 +30,7 @@ from ..base.interfaces_view import BaseInterfaceTableView from ..base.ip_addresses_view import BaseIPAddressTableView from ..base.librenms_sync_view import BaseLibreNMSSyncView +from ..base.modules_view import BaseModuleTableView from ..base.vlan_table_view import BaseVLANTableView from ..mixins import CacheMixin, LibreNMSPermissionMixin @@ -63,6 +65,12 @@ def get_vlan_context(self, request, obj): vlan_table_view.request = request return vlan_table_view.get_vlan_context(request, obj) + def get_module_context(self, request, obj): + """Return module sync context for the device.""" + module_table_view = DeviceModuleTableView() + module_table_view.request = request + return module_table_view.get_context_data(request, obj) + class DeviceInterfaceTableView(BaseInterfaceTableView): """Interface synchronization table for Devices.""" @@ -79,13 +87,22 @@ def get_redirect_url(self, obj): def get_table(self, data, obj, interface_name_field, vlan_groups=None): """Return the appropriate interface table, selecting VC variant if needed.""" + server_key = self.librenms_api.server_key if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: table = VCInterfaceTable( - data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups + data, + device=obj, + interface_name_field=interface_name_field, + vlan_groups=vlan_groups, + server_key=server_key, ) else: table = LibreNMSInterfaceTable( - data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups + data, + device=obj, + interface_name_field=interface_name_field, + vlan_groups=vlan_groups, + server_key=server_key, ) table.htmx_url = f"{self.request.path}?tab=interfaces" return table @@ -375,3 +392,15 @@ class DeviceVLANTableView(BaseVLANTableView): """VLAN synchronization table view for Devices.""" model = Device + + +class DeviceModuleTableView(BaseModuleTableView): + """Module/inventory synchronization view for Devices.""" + + model = Device + + def get_table(self, data, obj): + """Return the module sync table.""" + table = LibreNMSModuleTable(data, device=obj, server_key=self.librenms_api.server_key) + table.htmx_url = f"{self.request.path}?tab=modules" + return table diff --git a/netbox_librenms_plugin/views/object_sync/vms.py b/netbox_librenms_plugin/views/object_sync/vms.py index 51143d909d..bd6e052488 100644 --- a/netbox_librenms_plugin/views/object_sync/vms.py +++ b/netbox_librenms_plugin/views/object_sync/vms.py @@ -45,7 +45,9 @@ class VMInterfaceTableView(BaseInterfaceTableView): def get_table(self, data, obj, interface_name_field, vlan_groups=None): """Return a VM interface table for the given data.""" - return LibreNMSVMInterfaceTable(data, device=obj, vlan_groups=vlan_groups) + return LibreNMSVMInterfaceTable( + data, device=obj, vlan_groups=vlan_groups, server_key=self.librenms_api.server_key + ) def get_interfaces(self, obj): """Return all interfaces for the virtual machine.""" diff --git a/netbox_librenms_plugin/views/sync/cables.py b/netbox_librenms_plugin/views/sync/cables.py index 0e52f3b017..27dc5943c2 100644 --- a/netbox_librenms_plugin/views/sync/cables.py +++ b/netbox_librenms_plugin/views/sync/cables.py @@ -1,3 +1,5 @@ +import logging + from dcim.models import Cable, Device, Interface from django.contrib import messages from django.core.cache import cache @@ -9,6 +11,8 @@ from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin +logger = logging.getLogger(__name__) + class SyncCablesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): """Create NetBox cables using cached LibreNMS link data.""" @@ -42,7 +46,11 @@ def get_cached_links_data(self, request, obj): return cached_data.get("links", []) def create_cable(self, local_interface, remote_interface, request): - """Create a cable between local and remote interfaces.""" + """Create a cable between local and remote interfaces. + + Returns: + True on success, False on failure. + """ try: Cable.objects.create( a_terminations=[local_interface], @@ -81,13 +89,12 @@ def process_single_interface(self, interface, cached_links): link_data = next(link for link in cached_links if link["local_port"] == interface["interface"]) return self.handle_cable_creation(link_data, interface) except StopIteration: - return {"status": "invalid"} + return {"status": "invalid", "interface": interface.get("interface", "")} def verify_cable_creation_requirements(self, link_data): """Return True if all required NetBox IDs are present in link data.""" required_fields = [ "netbox_local_interface_id", - "netbox_remote_device_id", "netbox_remote_interface_id", ] @@ -113,13 +120,21 @@ def handle_cable_creation(self, link_data, interface): return {"status": "missing_remote", "interface": interface["interface"]} def process_interface_sync(self, selected_interfaces, cached_links): - """Process cable sync for all selected interfaces and return results.""" + """Process cable sync for all selected interfaces and return results. + + Each interface is processed in its own atomic block so individual + failures roll back only that cable without affecting others. + """ results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []} - with transaction.atomic(): - for interface in selected_interfaces: - result = self.process_single_interface(interface, cached_links) + for interface in selected_interfaces: + try: + with transaction.atomic(): + result = self.process_single_interface(interface, cached_links) results[result["status"]].append(result.get("interface", "")) + except Exception: + logger.exception("Failed to sync cable for interface %s", interface.get("interface", "")) + results["invalid"].append(interface.get("interface", "")) return results diff --git a/netbox_librenms_plugin/views/sync/device_fields.py b/netbox_librenms_plugin/views/sync/device_fields.py index 29eed67c2a..180b3ccf13 100644 --- a/netbox_librenms_plugin/views/sync/device_fields.py +++ b/netbox_librenms_plugin/views/sync/device_fields.py @@ -1,13 +1,16 @@ from dcim.models import Device, Manufacturer, Platform from django.contrib import messages from django.core.exceptions import ValidationError -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404, redirect from django.views import View +import logging from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin +logger = logging.getLogger(__name__) + class UpdateDeviceNameView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Update NetBox device name from LibreNMS sysName.""" @@ -278,28 +281,43 @@ def post(self, request, pk): pass try: - platform = Platform.objects.create( - name=platform_name, - manufacturer=manufacturer, + with transaction.atomic(): + platform = Platform.objects.create( + name=platform_name, + manufacturer=manufacturer, + ) + + device.platform = platform + device.full_clean() + device.save() + except IntegrityError as e: + error_str = str(e) + logger.error( + f"IntegrityError creating platform '{platform_name}' for device pk={pk}: {e}", + exc_info=True, + ) + if "platform" in error_str.lower() or "slug" in error_str.lower(): + messages.error( + request, + f"Platform '{platform_name}' could not be created (slug collision). Try a different name.", + ) + else: + messages.error( + request, + f"Failed to assign platform '{platform_name}'. Please contact an administrator.", + ) + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + except ValidationError as e: + logger.error( + f"ValidationError assigning platform '{platform_name}' to device pk={pk}: {e}", + exc_info=True, ) - except IntegrityError: messages.error( request, - f"Platform '{platform_name}' could not be created (slug collision). Try a different name.", + f"Failed to assign platform '{platform_name}'. Please contact an administrator.", ) return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) - old_platform = device.platform - device.platform = platform - try: - device.full_clean() - device.save() - except (ValidationError, IntegrityError) as e: - device.platform = old_platform - error_msg = e.message_dict if hasattr(e, "message_dict") else str(e) - messages.error(request, f"Failed to assign platform '{platform}': {error_msg}") - return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) - messages.success( request, f"Created platform '{platform}' and assigned to device", @@ -382,3 +400,75 @@ def post(self, request, pk): messages.info(request, "No serial assignments were made") return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + + +class RemoveServerMappingView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, View): + """Remove a single server entry from the device's librenms_id custom field dict.""" + + required_object_permissions = { + "POST": [("change", Device)], + } + + def post(self, request, pk): + if error := self.require_all_permissions("POST"): + return error + + device = get_object_or_404(Device, pk=pk) + server_key = request.POST.get("server_key", "").strip() + + if not server_key: + messages.error(request, "No server_key provided.") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + + cf_value = device.custom_field_data.get("librenms_id") + if not isinstance(cf_value, dict) or server_key not in cf_value: + messages.warning(request, f"No mapping found for server '{server_key}'.") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + + # Refuse to remove mappings for servers that are still configured in the plugin. + # Only orphaned (unconfigured) mappings may be removed via this endpoint. + # Guard both multi-server mode (servers dict) and legacy single-server mode + # (top-level librenms_url in plugin config, which implicitly defines "default"). + from django.conf import settings as django_settings + + plugins_cfg = django_settings.PLUGINS_CONFIG.get("netbox_librenms_plugin", {}) + configured_servers = plugins_cfg.get("servers", {}) + legacy_url_configured = bool(plugins_cfg.get("librenms_url")) + if server_key in configured_servers or (legacy_url_configured and server_key == "default"): + messages.error( + request, + f"Cannot remove mapping for configured server '{server_key}'. " + "Remove the server from plugin configuration first, then retry.", + ) + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + + with transaction.atomic(): + try: + device_locked = Device.objects.select_for_update().get(pk=pk) + except Device.DoesNotExist: + messages.error(request, "Device no longer exists.") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + cf = device_locked.custom_field_data.get("librenms_id", {}) + # Re-check after acquiring lock; mirror the pre-transaction protection logic + _is_protected = server_key in configured_servers or (legacy_url_configured and server_key == "default") + if isinstance(cf, dict) and server_key in cf and not _is_protected: + del cf[server_key] + device_locked.custom_field_data["librenms_id"] = cf if cf else None + try: + device_locked.full_clean() + device_locked.save() + except ValidationError as exc: + transaction.set_rollback(True) + logger.error("Validation error removing LibreNMS mapping for server %r: %s", server_key, exc) + messages.error(request, "Validation error removing LibreNMS mapping.") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + except Exception: + transaction.set_rollback(True) + logger.exception("Unexpected error removing LibreNMS mapping for server %r", server_key) + messages.error(request, "An unexpected error occurred while removing the LibreNMS mapping.") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + messages.success(request, f"Removed LibreNMS mapping for server '{server_key}'.") + else: + messages.warning(request, f"Mapping for server '{server_key}' was already removed.") + + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) diff --git a/netbox_librenms_plugin/views/sync/devices.py b/netbox_librenms_plugin/views/sync/devices.py index da0f9af5b0..eb45b375ff 100644 --- a/netbox_librenms_plugin/views/sync/devices.py +++ b/netbox_librenms_plugin/views/sync/devices.py @@ -26,7 +26,7 @@ def get_object(self, object_id): try: return Device.objects.get(pk=object_id) except Device.DoesNotExist: - return VirtualMachine.objects.get(pk=object_id) + return get_object_or_404(VirtualMachine, pk=object_id) def post(self, request, object_id): """Add a device to LibreNMS using the submitted SNMP form.""" diff --git a/netbox_librenms_plugin/views/sync/interfaces.py b/netbox_librenms_plugin/views/sync/interfaces.py index 1100e0da1b..3846de1714 100644 --- a/netbox_librenms_plugin/views/sync/interfaces.py +++ b/netbox_librenms_plugin/views/sync/interfaces.py @@ -9,16 +9,19 @@ from virtualization.models import VirtualMachine, VMInterface from netbox_librenms_plugin.models import InterfaceTypeMapping -from netbox_librenms_plugin.utils import convert_speed_to_kbps, get_interface_name_field +from netbox_librenms_plugin.utils import convert_speed_to_kbps, get_interface_name_field, set_librenms_device_id from netbox_librenms_plugin.views.mixins import ( CacheMixin, + LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, VlanAssignmentMixin, ) -class SyncInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, VlanAssignmentMixin, CacheMixin, View): +class SyncInterfacesView( + LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, VlanAssignmentMixin, CacheMixin, View +): """Sync selected interfaces from LibreNMS into NetBox.""" def get_required_permissions_for_object_type(self, object_type): @@ -164,14 +167,8 @@ def sync_interface(self, obj, librenms_interface, exclude_columns, interface_nam ) # Sync VLANs if not excluded - vlan_synced = False if "vlans" not in exclude_columns: self._sync_interface_vlans(interface, librenms_interface, interface_name) - vlan_synced = True - - # Skip redundant save when _sync_interface_vlans already saved (via _update_interface_vlan_assignment) - if not vlan_synced: - interface.save() def get_netbox_interface_type(self, librenms_interface): """Return the NetBox interface type mapped from LibreNMS type and speed.""" @@ -196,7 +193,8 @@ def handle_mac_address(self, interface, ifPhysAddress): mac_obj = MACAddress.objects.create(mac_address=ifPhysAddress) interface.mac_addresses.add(mac_obj) - interface.primary_mac_address = mac_obj + if hasattr(interface, "primary_mac_address"): + interface.primary_mac_address = mac_obj def update_interface_attributes( self, @@ -235,7 +233,9 @@ def update_interface_attributes( setattr(interface, netbox_key, librenms_interface.get(librenms_key)) if "librenms_id" in interface.cf: - interface.custom_field_data["librenms_id"] = librenms_interface.get("port_id") + port_id = librenms_interface.get("port_id") + if port_id is not None: + set_librenms_device_id(interface, port_id, self.librenms_api.server_key) if "enabled" not in exclude_columns: admin_status = librenms_interface.get("ifAdminStatus") diff --git a/netbox_librenms_plugin/views/sync/modules.py b/netbox_librenms_plugin/views/sync/modules.py new file mode 100644 index 0000000000..cba1679a77 --- /dev/null +++ b/netbox_librenms_plugin/views/sync/modules.py @@ -0,0 +1,497 @@ +"""Sync action views for module/inventory installation from LibreNMS.""" + +from django.contrib import messages +from django.core.cache import cache +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.views import View + +from netbox_librenms_plugin.views.mixins import ( + CacheMixin, + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, +) + + +class InstallModuleView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): + """Install a NetBox Module into a ModuleBay from LibreNMS inventory data.""" + + def post(self, request, pk): + from dcim.models import Device, Module, ModuleBay, ModuleType + + self.required_object_permissions = {"POST": [("add", Module)]} + if error := self.require_all_permissions("POST"): + return error + + device = get_object_or_404(Device, pk=pk) + module_bay_id = request.POST.get("module_bay_id") + module_type_id = request.POST.get("module_type_id") + serial = request.POST.get("serial", "").strip() + + if not module_bay_id or not module_type_id: + messages.error(request, "Missing module bay or module type.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + module_bay = get_object_or_404(ModuleBay, pk=module_bay_id, device=device) + module_type = get_object_or_404(ModuleType, pk=module_type_id) + + # Check if bay already has a module installed + if hasattr(module_bay, "installed_module") and module_bay.installed_module: + messages.warning(request, f"Module bay '{module_bay.name}' already has a module installed.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + try: + with transaction.atomic(): + module = Module( + device=device, + module_bay=module_bay, + module_type=module_type, + serial=serial, + status="active", + ) + module.full_clean() + module.save() + + messages.success( + request, f"Installed {module_type.model} in {module_bay.name} (serial: {serial or 'N/A'})." + ) + except Exception as e: + messages.error(request, f"Failed to install module: {e}") + + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + +class InstallBranchView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): + """Install a module and all its installable descendants from LibreNMS inventory.""" + + def post(self, request, pk): + from dcim.models import Device, Module, ModuleBay, ModuleType + + self.required_object_permissions = {"POST": [("add", Module)]} + if error := self.require_all_permissions("POST"): + return error + + device = get_object_or_404(Device, pk=pk) + parent_index = request.POST.get("parent_index") + server_key = request.POST.get("server_key") or None + + if not parent_index: + messages.error(request, "Missing parent inventory index.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + try: + parent_index = int(parent_index) + except ValueError: + messages.error(request, "Invalid parent inventory index.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + # Get cached inventory data + cached_data = cache.get(self.get_cache_key(device, "inventory", server_key=server_key)) + if not cached_data: + messages.error(request, "No cached inventory data. Please refresh modules first.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + # Build index map and collect the branch to install + index_map = {idx: item for item in cached_data if (idx := item.get("entPhysicalIndex")) is not None} + branch_items = self._collect_branch(parent_index, cached_data) + + if not branch_items: + messages.warning(request, "No installable items found in this branch.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + # Load module types (with mappings) + module_types = self._get_module_types() + + # Preload all ModuleBayMappings once to avoid N+1 per-item queries + from netbox_librenms_plugin.models import ModuleBayMapping + + all_mappings = list(ModuleBayMapping.objects.all()) + exact_mappings = [m for m in all_mappings if not m.is_regex] + regex_mappings = [m for m in all_mappings if m.is_regex] + + # Install top-down: each install may create new child bays + installed = [] + skipped = [] + failed = [] + + try: + with transaction.atomic(): + for item in branch_items: + result = self._install_single( + device, + item, + index_map, + module_types, + ModuleBay, + ModuleType, + Module, + exact_mappings=exact_mappings, + regex_mappings=regex_mappings, + ) + if result["status"] == "installed": + installed.append(result["name"]) + elif result["status"] == "skipped": + skipped.append(f"{result['name']}: {result['reason']}") + else: + failed.append(f"{result['name']}: {result['reason']}") + except Exception as e: + messages.error(request, f"Branch install failed: {e}") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + # Report results + if installed: + messages.success(request, f"Installed {len(installed)} module(s): {', '.join(installed)}") + if skipped: + messages.info(request, f"Skipped {len(skipped)}: {'; '.join(skipped)}") + if failed: + messages.warning(request, f"Failed {len(failed)}: {'; '.join(failed)}") + + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + def _collect_branch(self, parent_index, inventory_data): + """Collect all items in a branch depth-first, parent first. + + Returns items in install order (parent before children). + """ + items = [] + parent = next((i for i in inventory_data if i.get("entPhysicalIndex") == parent_index), None) + if parent: + model = (parent.get("entPhysicalModelName") or "").strip() + if model: + items.append(parent) + self._collect_children(parent_index, inventory_data, items, visited={parent_index}) + return items + + def _collect_children(self, parent_idx, inventory_data, items, visited=None): + """Recursively collect children with models, depth-first.""" + if visited is None: + visited = set() + children = [i for i in inventory_data if i.get("entPhysicalContainedIn") == parent_idx] + for child in children: + child_idx = child.get("entPhysicalIndex") + if child_idx is None: + continue + if child_idx in visited: + continue + visited.add(child_idx) + model = (child.get("entPhysicalModelName") or "").strip() + if model: + items.append(child) + # Always recurse to find deeper items (containers may lack models) + self._collect_children(child_idx, inventory_data, items, visited) + + def _get_module_types(self): + """Get all module types indexed by model, with mappings applied.""" + from netbox_librenms_plugin.utils import get_module_types_indexed + + return get_module_types_indexed() + + def _install_single( + self, + device, + item, + index_map, + module_types, + ModuleBay, + ModuleType, + Module, + exact_mappings=None, + regex_mappings=None, + ): + """Try to install a single inventory item. + + Re-fetches module bays each time since parent installs create new ones. + Scopes bay lookup to the correct parent module to handle duplicate bay names. + """ + from netbox_librenms_plugin.utils import apply_normalization_rules + + model_name = (item.get("entPhysicalModelName") or "").strip() + serial = (item.get("entPhysicalSerialNum") or "").strip() + name = item.get("entPhysicalName", "") or model_name + + # Match module type (direct, then normalization fallback) + matched_type = module_types.get(model_name) + if not matched_type and model_name: + manufacturer = getattr(getattr(device, "device_type", None), "manufacturer", None) + normalized = apply_normalization_rules(model_name, "module_type", manufacturer=manufacturer) + if normalized != model_name: + matched_type = module_types.get(normalized) + if not matched_type: + return {"status": "skipped", "name": name, "reason": "no matching type"} + + # Re-fetch module bays (parent install creates new child bays) + bays = ModuleBay.objects.filter(device=device).select_related("installed_module__module_type") + + # Use preloaded mappings if provided, otherwise load from DB + if exact_mappings is None or regex_mappings is None: + from netbox_librenms_plugin.models import ModuleBayMapping + + all_mappings = list(ModuleBayMapping.objects.all()) + exact_mappings = [m for m in all_mappings if not m.is_regex] + regex_mappings = [m for m in all_mappings if m.is_regex] + + bay_mappings = exact_mappings + regex_mappings + + # Determine if this item belongs under an installed module + # by tracing its LibreNMS parent hierarchy to an installed item + parent_module_id = self._find_parent_module_id(item, index_map, bays, bay_mappings) + + if parent_module_id: + bay_dict = {bay.name: bay for bay in bays if bay.module_id == parent_module_id} + else: + bay_dict = {bay.name: bay for bay in bays if not bay.module_id} + + # Match module bay using preloaded mapping data + matched_bay = self._match_bay(item, index_map, bay_dict, exact_mappings, regex_mappings) + if not matched_bay: + return {"status": "skipped", "name": name, "reason": "no matching bay"} + + # Check if already installed + if hasattr(matched_bay, "installed_module") and matched_bay.installed_module: + return {"status": "skipped", "name": name, "reason": "bay already occupied"} + + # Install + try: + with transaction.atomic(): # savepoint: failure here won't abort parent tx + module = Module( + device=device, + module_bay=matched_bay, + module_type=matched_type, + serial=serial, + status="active", + ) + module.full_clean() + module.save() + except Exception as e: + error_msg = str(e) + if "dcim_interface_unique_device_name" in error_msg: + error_msg = ( + "duplicate interface name β€” this module type's interface template " + "uses {module} which resolves to the same name for all siblings. " + "An interface naming plugin with a rewrite rule for this module type can fix this." + ) + return {"status": "failed", "name": name, "reason": error_msg} + + return {"status": "installed", "name": f"{matched_type.model} β†’ {matched_bay.name}"} + + @staticmethod + def _find_parent_module_id(item, index_map, device_bays, bay_mappings): + """Find the NetBox module ID for the installed parent of this inventory item. + + Walks up the LibreNMS hierarchy to find an ancestor whose name matches + an installed module bay on the device. + + Args: + item: The inventory item dict. + index_map: Dict mapping entPhysicalIndex to inventory item. + device_bays: Pre-fetched queryset/list of ModuleBay objects for the device. + bay_mappings: Pre-fetched list of all ModuleBayMapping objects. + """ + + current = item + # Build bay name->bay dict from pre-fetched bays for fast lookup + bay_by_name = {} + for bay in device_bays: + if bay.name not in bay_by_name: + bay_by_name[bay.name] = bay + # Build mapping dict keyed by librenms_name for fast lookup + mapping_by_name = {} + for m in bay_mappings: + if m.librenms_name not in mapping_by_name: + mapping_by_name[m.librenms_name] = m + + visited = set() + while True: + parent_idx = current.get("entPhysicalContainedIn", 0) + if not parent_idx or parent_idx not in index_map: + return None + if parent_idx in visited: + return None + visited.add(parent_idx) + parent = index_map[parent_idx] + parent_name = parent.get("entPhysicalName", "") + parent_descr = parent.get("entPhysicalDescr", "") + + # Check if this parent matches an installed module bay on the device + for bay in device_bays: + if hasattr(bay, "installed_module") and bay.installed_module: + if bay.name == parent_name or (parent_descr and bay.name == parent_descr): + return bay.installed_module.pk + + # Also check ModuleBayMapping for indirect matches using pre-fetched data + for name in [parent_name, parent_descr]: + if not name: + continue + mapping = mapping_by_name.get(name) + if mapping: + bay = bay_by_name.get(mapping.netbox_bay_name) + if bay and hasattr(bay, "installed_module") and bay.installed_module: + return bay.installed_module.pk + + current = parent + + @staticmethod + def _match_bay(item, index_map, module_bays, exact_mappings, regex_mappings): + """Match an inventory item to a module bay (same logic as BaseModuleTableView).""" + import re + + from netbox_librenms_plugin.views.base.modules_view import BaseModuleTableView + + # Resolve parent name by walking up the containment hierarchy + contained_in = item.get("entPhysicalContainedIn", 0) + parent_name = None + if contained_in: + visited_anc = set() + current_idx = contained_in + while current_idx and current_idx not in visited_anc: + visited_anc.add(current_idx) + ancestor = index_map.get(current_idx) + if not ancestor: + break + ancestor_name = ancestor.get("entPhysicalName", "") + if ancestor_name: + parent_name = ancestor_name + break + current_idx = ancestor.get("entPhysicalContainedIn", 0) + + item_name = item.get("entPhysicalName", "") + item_descr = item.get("entPhysicalDescr", "") + phys_class = item.get("entPhysicalClass", "") + + # Build candidate names: parent, item name, item description + candidate_names = [n for n in [parent_name, item_name, item_descr] if n] + + # Check mapping for each candidate (exact match, in-memory lookup) + # Group exact_mappings by (librenms_name, librenms_class) for O(1) lookup + exact_by_name: dict = {} + for m in exact_mappings: + exact_by_name.setdefault(m.librenms_name, []).append(m) + + for name in candidate_names: + candidates_for_name = exact_by_name.get(name, []) + mapping = None + if phys_class: + mapping = next((m for m in candidates_for_name if m.librenms_class == phys_class), None) + if not mapping: + mapping = next((m for m in candidates_for_name if m.librenms_class == ""), None) + if mapping and mapping.netbox_bay_name in module_bays: + return module_bays[mapping.netbox_bay_name] + + # Regex pattern matching using preloaded list + for name in candidate_names: + bay = BaseModuleTableView._lookup_regex_bay_mapping(re, name, phys_class, module_bays, regex_mappings) + if bay: + return bay + + # Fallback: exact match on candidate names against bay dict + for name in candidate_names: + if name in module_bays: + return module_bays[name] + + # Positional fallback for items inside converters + return BaseModuleTableView._match_bay_by_position(item, index_map, module_bays) + + +class InstallSelectedView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): + """Install a user-selected set of inventory items by their entPhysicalIndex values. + + Reuses InstallBranchView._install_single for each selected item so every item + goes through the same type/bay/serial resolution pipeline as a branch install. + Only items where a matching bay *and* module type are found will be installed; + items with no bay or no type are silently skipped (same behaviour as branch). + """ + + def post(self, request, pk): + from dcim.models import Device, Module, ModuleBay, ModuleType + + self.required_object_permissions = {"POST": [("add", Module)]} + if error := self.require_all_permissions("POST"): + return error + + device = get_object_or_404(Device, pk=pk) + server_key = request.POST.get("server_key") or None + + selected_indices = request.POST.getlist("select") + if not selected_indices: + messages.warning(request, "No modules selected.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + cached_data = cache.get(self.get_cache_key(device, "inventory", server_key=server_key)) + if not cached_data: + messages.error(request, "No cached inventory data. Please refresh modules first.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + try: + # Use dict.fromkeys to preserve order while deduplicating + selected_list = list(dict.fromkeys(int(i) for i in selected_indices)) + except ValueError: + messages.error(request, "Invalid selection.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + index_map = {idx: item for item in cached_data if (idx := item.get("entPhysicalIndex")) is not None} + items = [index_map[idx] for idx in selected_list if idx in index_map] + + if not items: + messages.warning(request, "None of the selected indices matched cached inventory.") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + helper = InstallBranchView() + module_types = helper._get_module_types() + + # Preload all ModuleBayMappings once to avoid N+1 per-item queries + from netbox_librenms_plugin.models import ModuleBayMapping + + all_mappings = list(ModuleBayMapping.objects.all()) + exact_mappings = [m for m in all_mappings if not m.is_regex] + regex_mappings = [m for m in all_mappings if m.is_regex] + + installed, skipped, failed = [], [], [] + + try: + with transaction.atomic(): + for item in items: + result = helper._install_single( + device, + item, + index_map, + module_types, + ModuleBay, + ModuleType, + Module, + exact_mappings=exact_mappings, + regex_mappings=regex_mappings, + ) + if result["status"] == "installed": + installed.append(result["name"]) + elif result["status"] == "skipped": + skipped.append(f"{result['name']}: {result['reason']}") + else: + failed.append(f"{result['name']}: {result['reason']}") + except Exception as e: + messages.error(request, f"Install failed: {e}") + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") + + if installed: + messages.success(request, f"Installed {len(installed)} module(s): {', '.join(installed)}") + if skipped: + messages.info(request, f"Skipped {len(skipped)}: {'; '.join(skipped)}") + if failed: + messages.warning(request, f"Failed {len(failed)}: {'; '.join(failed)}") + + sync_url = reverse("plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": pk}) + return redirect(f"{sync_url}?tab=modules#librenms-module-table") diff --git a/pyproject.toml b/pyproject.toml index 4620828506..316285ba8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,11 @@ addopts = "-v --tb=short" [tool.ruff] line-length = 120 +[tool.ruff.lint.mccabe] +#Flag errors (`C901`) whenever the complexity level exceeds 15. +#Rule not enforced - only to bump default 10 to 15 to allow for manual check +max-complexity = 15 + [tool.ruff.lint] # Follow NetBox conventions - ignore certain rules ignore = [ diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000000..62c68691ec --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,6 @@ +"""Conftest for e2e tests β€” no Django initialization needed.""" + +import os + +# Prevent pytest-django from trying to initialize Django +os.environ.pop("DJANGO_SETTINGS_MODULE", None) diff --git a/tests/e2e/test_module_install.py b/tests/e2e/test_module_install.py new file mode 100644 index 0000000000..cee05eb904 --- /dev/null +++ b/tests/e2e/test_module_install.py @@ -0,0 +1,330 @@ +"""End-to-end Playwright tests for LibreNMS plugin module sync workflow. + +These tests exercise the full import β†’ modules β†’ install flow against a +live NetBox + LibreNMS instance inside the devcontainer. + +Prerequisites: + - NetBox running at NETBOX_URL (default http://172.22.0.4:8000) + - LibreNMS server configured in plugin settings + - Device 15 (WS-C4900M) exists and is linked to LibreNMS + - Playwright installed: pip install playwright && playwright install chromium + +Run: + cd /home/mzieba/workspace/netbox-librenms-plugin + HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \ + no_proxy=localhost,127.0.0.1,172.22.0.4 \ + python -m pytest tests/e2e/test_module_install.py -v -s +""" + +import os +import subprocess +import time + +import pytest + +NETBOX_URL = os.environ.get("NETBOX_URL", "http://172.22.0.4:8000") +NETBOX_USER = os.environ.get("NETBOX_USER", "admin") +NETBOX_PASS = os.environ.get("NETBOX_PASS", "admin") +CONTAINER_NAME = None + + +def _get_container(): + """Find the devcontainer name.""" + global CONTAINER_NAME + if CONTAINER_NAME: + return CONTAINER_NAME + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + for name in result.stdout.strip().split("\n"): + if "devcontainer-devcontainer" in name: + CONTAINER_NAME = name + return name + pytest.skip("No devcontainer found") + + +def _netbox_shell(code): + """Run Python code in NetBox's Django shell.""" + import shlex + + container = _get_container() + escaped = shlex.quote(code) + result = subprocess.run( + [ + "docker", + "exec", + container, + "bash", + "-c", + f"cd /opt/netbox/netbox && python3 manage.py shell -c {escaped}", + ], + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin", "HOME": "/root"}, + ) + # Filter out config loading lines + lines = [line for line in result.stdout.strip().split("\n") if not line.startswith(("🧬", "156 objects"))] + return "\n".join(lines).strip() + + +def _delete_device_modules(device_id): + """Remove all modules from a device.""" + _netbox_shell( + f"from dcim.models import Module; " + f"deleted = Module.objects.filter(device_id={device_id}).delete(); " + f"print(f'Deleted {{deleted}}')" + ) + + +def _get_interfaces(device_id): + """Get interface names for a device.""" + output = _netbox_shell( + f"from dcim.models import Interface; " + f'[print(f\'{{i.name}}|{{i.module.module_type.model if i.module else "-"}}|' + f'{{i.module.module_bay.name if i.module else "-"}}\')' + f" for i in Interface.objects.filter(device_id={device_id}).order_by('name')]" + ) + results = [] + for line in output.split("\n"): + if "|" in line: + name, mod_type, bay = line.split("|") + results.append({"name": name, "module_type": mod_type, "bay": bay}) + return results + + +@pytest.fixture(scope="module") +def browser(): + """Launch browser for the test module.""" + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + b = pw.chromium.launch(headless=True) + yield b + b.close() + pw.stop() + + +@pytest.fixture +def page(browser): + """Create a new page and log in to NetBox.""" + ctx = browser.new_context(ignore_https_errors=True) + pg = ctx.new_page() + + pg.goto(f"{NETBOX_URL}/login/", timeout=10000) + pg.fill("#id_username", NETBOX_USER) + pg.fill("#id_password", NETBOX_PASS) + pg.click("button[type=submit]") + pg.wait_for_load_state("networkidle") + yield pg + ctx.close() + + +class TestModuleInstallWorkflow: + """Test the full module sync and install workflow on device 15 (WS-C4900M).""" + + DEVICE_ID = 15 + + def _goto_modules_tab(self, page): + """Navigate to the modules sync tab and refresh data.""" + page.goto(f"{NETBOX_URL}/dcim/devices/{self.DEVICE_ID}/librenms-sync/?tab=modules") + page.wait_for_load_state("networkidle") + time.sleep(2) + + # Click Refresh Modules + btn = page.query_selector('button:has-text("Refresh Modules")') + assert btn is not None, "Refresh Modules button not found" + btn.click() + time.sleep(8) + + def _get_table_rows(self, page): + """Parse the module sync table into dicts.""" + pane = page.query_selector("#modules") + assert pane is not None, "Modules pane not found" + + rows = [] + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if len(cells) >= 8: + rows.append( + { + "name": cells[0].inner_text().strip(), + "model": cells[1].inner_text().strip(), + "serial": cells[2].inner_text().strip(), + "bay": cells[5].inner_text().strip(), + "type": cells[6].inner_text().strip(), + "status": cells[7].inner_text().strip(), + } + ) + return rows + + def test_clean_state_shows_install_buttons(self, page): + """After deleting all modules, table shows Install buttons.""" + _delete_device_modules(self.DEVICE_ID) + self._goto_modules_tab(page) + + rows = self._get_table_rows(page) + assert len(rows) > 0, "No rows in module sync table" + + # Top-level items with matched bays should show Matched status + supervisor = [r for r in rows if "Supervisor(slot 1)" in r["name"]] + assert len(supervisor) == 1, f"Expected 1 Supervisor row, got {len(supervisor)}" + assert supervisor[0]["status"] == "Matched", f"Expected Matched, got {supervisor[0]['status']}" + + def test_single_install(self, page): + """Installing a single top-level module works.""" + _delete_device_modules(self.DEVICE_ID) + self._goto_modules_tab(page) + + # Install FanTray 1 + pane = page.query_selector("#modules") + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and "FanTray 1" in cells[0].inner_text(): + btn = tr.query_selector('button:has-text("Install"):not(:has-text("Branch"))') + if btn: + btn.click() + time.sleep(5) + break + + # Verify via DB + output = _netbox_shell( + f"from dcim.models import Module; " + f"m = Module.objects.filter(device_id={self.DEVICE_ID}, module_bay__name='Fan Tray 1').first(); " + f"print(m.module_type.model if m else 'NONE')" + ) + assert "WS-X4992" in output, f"FanTray not installed: {output}" + + def test_branch_install_supervisor(self, page): + """Branch install creates supervisor + X2 transceivers with correct names.""" + _delete_device_modules(self.DEVICE_ID) + self._goto_modules_tab(page) + + # Click Install Branch on Supervisor(slot 1) + pane = page.query_selector("#modules") + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and "Supervisor(slot 1)" in cells[0].inner_text(): + btn = tr.query_selector('button:has-text("Install Branch")') + assert btn is not None, "Install Branch button not found for Supervisor" + btn.click() + break + + # Wait for branch install to complete (creates many modules + signals) + time.sleep(20) + page.wait_for_load_state("networkidle") + time.sleep(5) + + # Verify interfaces have correct names (not bare position numbers) + interfaces = _get_interfaces(self.DEVICE_ID) + x2_interfaces = [i for i in interfaces if i["module_type"] in ("X2-10GB-LR", "X2-10GB-SR")] + + assert len(x2_interfaces) > 0, "No X2 transceiver interfaces created" + + for iface in x2_interfaces: + assert iface["name"].startswith("TenGigabitEthernet"), ( + f"Interface '{iface['name']}' in {iface['bay']} " + f"should start with 'TenGigabitEthernet' (INR rule not applied?)" + ) + + def test_branch_install_no_duplicate_errors(self, page): + """Branch install handles already-occupied bays gracefully.""" + # Don't delete modules β€” some should already be installed + self._goto_modules_tab(page) + + # Click Install Branch on Supervisor(slot 1) again + pane = page.query_selector("#modules") + branch_btn = None + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and "Supervisor(slot 1)" in cells[0].inner_text(): + branch_btn = tr.query_selector('button:has-text("Install Branch")') + break + + if branch_btn: + branch_btn.click() + time.sleep(10) + page.wait_for_load_state("networkidle") + time.sleep(2) + + # Check for error messages β€” should only have skips, no failures + body_text = page.query_selector("body").inner_text() + assert "Branch install failed" not in body_text, ( + "Branch install crashed instead of handling errors gracefully" + ) + + def test_child_bays_hidden_when_parent_not_installed(self, page): + """Children show 'No Bay' when parent module is not installed.""" + _delete_device_modules(self.DEVICE_ID) + self._goto_modules_tab(page) + + rows = self._get_table_rows(page) + + # Children of Supervisor(slot 1) should show "No matching bay" + # since Supervisor isn't installed, its child bays don't exist yet + children = [r for r in rows if r["name"].startswith("└─") and "TenGigabitEthernet1/" in r["name"]] + for child in children: + assert "No matching bay" in child["bay"], ( + f"Child '{child['name']}' should show 'No matching bay' when parent not installed, got '{child['bay']}'" + ) + + def test_full_workflow(self, page): + """Full workflow: clean β†’ install individuals β†’ branch install β†’ verify.""" + _delete_device_modules(self.DEVICE_ID) + self._goto_modules_tab(page) + + # Step 1: Install PSUs and FanTray individually + for label in ["FanTray 1", "Power Supply 1", "Power Supply 2"]: + pane = page.query_selector("#modules") + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and label in cells[0].inner_text(): + btn = tr.query_selector('button:has-text("Install"):not(:has-text("Branch"))') + if btn: + btn.click() + time.sleep(4) + break + + # Step 2: Branch install Supervisor + transceivers + pane = page.query_selector("#modules") + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and "Supervisor(slot 1)" in cells[0].inner_text(): + btn = tr.query_selector('button:has-text("Install Branch")') + if btn: + btn.click() + time.sleep(10) + break + + page.wait_for_load_state("networkidle") + time.sleep(2) + + # Step 3: Branch install Linecard + self._goto_modules_tab(page) + pane = page.query_selector("#modules") + for tr in pane.query_selector_all("table tr"): + cells = tr.query_selector_all("td") + if cells and "Linecard(slot 3)" in cells[0].inner_text(): + btn = tr.query_selector('button:has-text("Install Branch")') + if btn: + btn.click() + time.sleep(10) + break + + page.wait_for_load_state("networkidle") + time.sleep(2) + + # Verify: all installable modules should be installed + self._goto_modules_tab(page) + rows = self._get_table_rows(page) + + matched_but_not_installed = [r for r in rows if r["status"] == "Matched" and not r["name"].startswith("└─")] + assert len(matched_but_not_installed) == 0, ( + f"Top-level items still 'Matched' after full workflow: {[r['name'] for r in matched_but_not_installed]}" + ) + + # Verify interface naming + interfaces = _get_interfaces(self.DEVICE_ID) + for iface in interfaces: + assert iface["name"] != "1", "Interface with bare name '1' found β€” INR rule not applied" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..32ce70698f --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12.0" + +[[package]] +name = "netbox-librenms-plugin" +version = "0.4.3" +source = { editable = "." }