diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 1437c2ed67..11f80408df 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -31,3 +31,25 @@ SKIP_SUPERUSER=false # .devcontainer/plugin-config.py.example β†’ .devcontainer/plugin-config.py # Advanced NetBox configuration (optional): # .devcontainer/extra-configuration.py.example β†’ .devcontainer/extra-configuration.py + +# Proxy Configuration (optional, for corporate networks with MITM proxies) +# Uncomment and set these if you're behind a proxy +# HTTP_PROXY=http://proxy.example.com:8080 +# HTTPS_PROXY=http://proxy.example.com:8080 +# NO_PROXY=localhost,127.0.0.1,postgres,redis +# +# CA bundle configuration: +# Normally you SHOULD NOT set REQUESTS_CA_BUNDLE, SSL_CERT_FILE, or CURL_CA_BUNDLE here. +# Instead, place a ca-bundle.crt file in the workspace root and setup.sh will install it +# into the system trust store and set these variables automatically to: +# /etc/ssl/certs/ca-certificates.crt +# Only set the following manually for custom CA setups that cannot use the automatic +# configuration provided by setup.sh. +# REQUESTS_CA_BUNDLE=/custom/path/to/ca-bundle.crt +# SSL_CERT_FILE=/custom/path/to/ca-bundle.crt +# CURL_CA_BUNDLE=/custom/path/to/ca-bundle.crt + +# Git SSL verification override (default: false) +# Only set to true if behind a MITM proxy and you cannot provide a CA bundle. +# Prefer placing a ca-bundle.crt in the workspace root instead. +# ALLOW_GIT_SSL_DISABLE=false diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 98c4213cef..3560a93af3 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -65,6 +65,20 @@ If you need to test with a LibreNMS instance on a private network (local lab, co - **GitHub CLI**: Automatically configured for easy PR submission - **Logs**: Use `netbox-logs` to debug issues in real-time + +### πŸ“‘ LibreNMS Server Configuration + +You need a LibreNMS instance to use this plugin. Configure your LibreNMS server(s) in `plugin-config.py`: + +1. Copy the example config: + + ```bash + cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py + ``` + +2. Edit it with your LibreNMS server URL(s) and API token(s) +3. Restart NetBox: `netbox-restart` + ## Out-of-the-box defaults Below are the dev container defaults. The field name to change these defaults is listed below each line. @@ -139,6 +153,80 @@ You might experience issues with database schemas and migrations when changing N - Database: `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - Redis: `REDIS_HOST`, `REDIS_PASSWORD` - Superuser: `SUPERUSER_NAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD`, `SKIP_SUPERUSER` +- Proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE` + +### 🌐 Proxy Configuration (MITM Proxies) + +If you're behind a corporate proxy or MITM proxy (like Zscaler, BlueCoat, etc.), you need to configure the proxy at two levels: the Docker client (for building) and the container runtime (for package installation inside the container). + +**Step 1: Configure Docker client proxy** (`~/.docker/config.json`) + +This is **required** so that `apt-get`, `curl`, etc. work during the container image build (e.g., when installing devcontainer features like `git` and `github-cli`). + +Create or edit `~/.docker/config.json`: + +```json +{ + "proxies": { + "default": { + "httpProxy": "http://proxy.example.com:8080", + "httpsProxy": "http://proxy.example.com:8080", + "noProxy": "localhost,127.0.0.1,postgres,redis" + } + } +} +``` + +Docker automatically injects these as environment variables into every `RUN` instruction during `docker build`. No VS Code restart is needed β€” this takes effect immediately. + +> **Docker Desktop users:** You can configure the same settings via Docker Desktop Settings β†’ Resources β†’ Proxies, which writes this file for you. + +**Step 2: Create `.devcontainer/.env`** (for container runtime) + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Add your proxy settings to `.devcontainer/.env`: + +```bash +# Proxy Configuration +HTTP_PROXY=http://proxy.example.com:8080 +HTTPS_PROXY=http://proxy.example.com:8080 +NO_PROXY=localhost,127.0.0.1,postgres,redis +``` + +> **Note:** You do **not** need to set `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, or `CURL_CA_BUNDLE` manually. When a `ca-bundle.crt` file is present in the workspace root, `setup.sh` automatically installs it into the system trust store and sets these variables to `/etc/ssl/certs/ca-certificates.crt`. + +**Step 3: Add your CA certificate** (optional, only if your proxy intercepts TLS): + - Export your proxy's CA certificate (usually available from your IT department or browser) + - Save it as `ca-bundle.crt` in the root of your workspace + - `setup.sh` will automatically install it and configure CA bundle environment variables + +**Step 4: Rebuild the container**: + - VS Code: Ctrl+Shift+P β†’ "Dev Containers: Rebuild Container" + +**What gets configured:** +- `~/.docker/config.json` β†’ proxy for Docker build steps (devcontainer features, apt in Dockerfile) +- `.devcontainer/.env` β†’ proxy for running containers (apt, pip, curl at runtime) +- `setup.sh` auto-configures apt proxy and git SSL settings inside the container + +**Important Notes:** +- The `.env` file is ignored by git, so your proxy credentials stay private +- `~/.docker/config.json` is a per-user file outside the repo +- Add internal service names to `NO_PROXY` to avoid routing internal Docker traffic through the proxy +- **Proxy authentication:** Embedding credentials directly in the proxy URL (e.g., `http://username:password@proxy.example.com:8080`) is insecure β€” credentials can be visible in process listings, environment dumps, `docker inspect` output, and logs. Prefer safer alternatives such as Docker's `config.json` with `credsStore` or a secret manager for storing proxy credentials securely. + +**Common Issues:** + +*"Could not connect to archive.ubuntu.com" during build* +- β†’ `~/.docker/config.json` is missing or has wrong proxy URL + +*"SSL certificate errors" during build* +- β†’ Your proxy uses a MITM certificate. Export it and add it to the system trust store, or set `SSL_CERT_FILE` in `.env` + +*Container builds but apt/pip fails inside* +- β†’ .env file is missing or has wrong proxy settings. Check .env matches Docker Desktop settings After any `.env` change, rebuild the dev container to apply environment updates. diff --git a/.devcontainer/config/plugin-config.py.example b/.devcontainer/config/plugin-config.py.example index 71da51d4d9..5e48833a48 100644 --- a/.devcontainer/config/plugin-config.py.example +++ b/.devcontainer/config/plugin-config.py.example @@ -5,7 +5,7 @@ Default plugin configuration for the NetBox LibreNMS Plugin in the dev container - Copy this file to .devcontainer/plugin-config.py - Edit values as needed. -- Add config for all other plugins here if any. +- Add config for all other plugins here if any. """ # Ensure our plugin is enabled in dev (the loader sets this as a default too) @@ -13,13 +13,13 @@ PLUGINS = [ "netbox_librenms_plugin", ] -# Sample configuration with three example servers +# Sample configuration with example servers PLUGINS_CONFIG = { "netbox_librenms_plugin": { "servers": { "production": { "display_name": "Production LibreNMS", - "librenms_url": "https://librenms-prod.exampel.com", + "librenms_url": "https://librenms-prod.example.com", "api_token": "your-prod-token", "cache_timeout": 300, "verify_ssl": True, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 61548824e2..4f04987f20 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -34,12 +34,19 @@ "SUPERUSER_NAME": "${localEnv:SUPERUSER_NAME:admin}", "SUPERUSER_EMAIL": "${localEnv:SUPERUSER_EMAIL:admin@example.com}", "SUPERUSER_PASSWORD": "${localEnv:SUPERUSER_PASSWORD:admin}", - "SKIP_SUPERUSER": "${localEnv:SKIP_SUPERUSER:false}" - }, - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} + "SKIP_SUPERUSER": "${localEnv:SKIP_SUPERUSER:false}", + "HTTP_PROXY": "${localEnv:HTTP_PROXY}", + "HTTPS_PROXY": "${localEnv:HTTPS_PROXY}", + "http_proxy": "${localEnv:HTTP_PROXY}", + "https_proxy": "${localEnv:HTTPS_PROXY}", + "NO_PROXY": "${localEnv:NO_PROXY}", + "no_proxy": "${localEnv:NO_PROXY}", + "REQUESTS_CA_BUNDLE": "${localEnv:REQUESTS_CA_BUNDLE}", + "SSL_CERT_FILE": "${localEnv:SSL_CERT_FILE}", + "CURL_CA_BUNDLE": "${localEnv:CURL_CA_BUNDLE}", + "ALLOW_GIT_SSL_DISABLE": "${localEnv:ALLOW_GIT_SSL_DISABLE:false}" }, + "features": {}, "customizations": { "vscode": { "extensions": [ @@ -72,4 +79,4 @@ "postCreateCommand": "bash .devcontainer/scripts/setup.sh", "postAttachCommand": "bash .devcontainer/scripts/welcome.sh", "remoteUser": "root" -} \ No newline at end of file +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index d1b7ed03f4..4af2d072b0 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -22,6 +22,17 @@ services: SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-admin@example.com} SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-admin} SKIP_SUPERUSER: ${SKIP_SUPERUSER:-false} + # Proxy settings (optional) + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + http_proxy: ${HTTP_PROXY:-} + https_proxy: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-} + no_proxy: ${NO_PROXY:-} + REQUESTS_CA_BUNDLE: ${REQUESTS_CA_BUNDLE:-} + SSL_CERT_FILE: ${SSL_CERT_FILE:-} + CURL_CA_BUNDLE: ${CURL_CA_BUNDLE:-} + ALLOW_GIT_SSL_DISABLE: ${ALLOW_GIT_SSL_DISABLE:-false} depends_on: postgres: condition: service_healthy diff --git a/.devcontainer/scripts/load-aliases.sh b/.devcontainer/scripts/load-aliases.sh index 8628966f55..fc8a979d0c 100755 --- a/.devcontainer/scripts/load-aliases.sh +++ b/.devcontainer/scripts/load-aliases.sh @@ -61,4 +61,7 @@ alias rq-jobs="cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && alias rq-failed="cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell -c \"from django_rq import get_failed_queue; q = get_failed_queue(); print(f'Failed jobs: {len(q)}'); [print(f' {job.id[:8]}: {job.func_name}') for job in q.jobs[:10]]\"" alias rq-recent="cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell -c \"from core.models import Job; jobs = Job.objects.all().order_by('-created')[:10]; [print(f'{j.id}: {j.name[:50]} - {getattr(j.status, \\\"value\\\", j.status)} ({j.user})') for j in jobs]\"" -echo "βœ… Aliases loaded! Try: rq-status, rq-stats, rq-recent" +# Help +alias dev-help='echo "🎯 NetBox LibreNMS Plugin Development Commands:"; echo ""; echo "πŸ“Š NetBox Server Management:"; echo " netbox-run-bg : Start NetBox in background"; echo " netbox-run : Start NetBox in foreground (for debugging)"; echo " netbox-stop : Stop NetBox and RQ worker"; echo " netbox-restart : Restart NetBox and RQ worker"; echo " netbox-reload : Reinstall plugin and restart NetBox"; echo " netbox-status : Check if NetBox and RQ worker are running"; echo " netbox-logs : View NetBox server logs"; echo ""; echo "βš™οΈ Background Jobs (RQ Worker):"; echo " rq-status : Check if RQ worker is running"; echo " rq-logs : View RQ worker logs"; echo " rq-stats : Show RQ queue statistics"; echo " rq-jobs : List jobs in default queue"; echo " rq-failed : List failed jobs"; echo " rq-recent : Show recent NetBox jobs"; echo ""; echo "πŸ› οΈ Development Tools:"; echo " netbox-shell : Open NetBox Django shell"; echo " netbox-test : Run plugin tests"; echo " netbox-manage : Run Django management commands"; echo " plugin-install : Reinstall plugin in development mode"; echo ""; echo "🧹 Code Quality:"; echo " ruff-check : Check code with Ruff"; echo " ruff-format : Format code with Ruff"; echo " ruff-fix : Auto-fix code issues with Ruff"; echo ""; echo "πŸ”Ž Diagnostics:"; echo " diagnose : Run startup diagnostics"; echo " dev-help : Show this help message"; echo ""; echo "πŸ“– NetBox available at: http://localhost:8000 (admin/admin)"; echo ""' + +echo "βœ… Aliases loaded! Try: rq-status, rq-stats, rq-recent, dev-help" diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh old mode 100644 new mode 100755 index 18da8d24da..73372ded5a --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -7,6 +7,102 @@ echo "πŸ‘€ Current user: $(whoami)" NETBOX_VERSION=${NETBOX_VERSION:-"latest"} echo "πŸ“¦ Using NetBox Docker image: netboxcommunity/netbox:${NETBOX_VERSION}" +# --------------------------------------------------------------------------- +# Detect plugin workspace directory (must contain pyproject.toml). +# Prints the resolved path to stdout on success, or an empty string on +# failure. Always exits 0 β€” callers must check for an empty result. +# --------------------------------------------------------------------------- +detect_plugin_workspace() { + if [ -f "$PWD/pyproject.toml" ]; then + echo "$PWD" + elif [ -d "/workspaces/netbox-librenms-plugin" ] && [ -f "/workspaces/netbox-librenms-plugin/pyproject.toml" ]; then + echo "/workspaces/netbox-librenms-plugin" + else + local candidate + candidate=$(find /workspaces -maxdepth 2 -type f -name pyproject.toml 2>/dev/null | head -n1 | xargs -r dirname || true) + if [ -n "$candidate" ] && [ -f "$candidate/pyproject.toml" ]; then + echo "$candidate" + else + echo "" + fi + fi +} + +# Configure proxy for apt and pip if proxy environment variables are set +if [ -n "$HTTP_PROXY" ] || [ -n "$HTTPS_PROXY" ]; then + echo "🌐 Configuring proxy settings..." + + # Configure apt proxy + if [ -n "$HTTP_PROXY" ]; then + echo "Acquire::http::Proxy \"$HTTP_PROXY\";" > /etc/apt/apt.conf.d/80proxy + SAFE_HTTP_PROXY=$(echo "$HTTP_PROXY" | sed 's|://[^@]*@|://***:***@|') + echo " βœ“ apt HTTP proxy: $SAFE_HTTP_PROXY" + fi + if [ -n "$HTTPS_PROXY" ]; then + echo "Acquire::https::Proxy \"$HTTPS_PROXY\";" >> /etc/apt/apt.conf.d/80proxy + SAFE_HTTPS_PROXY=$(echo "$HTTPS_PROXY" | sed 's|://[^@]*@|://***:***@|') + echo " βœ“ apt HTTPS proxy: $SAFE_HTTPS_PROXY" + fi + + # Configure pip proxy via environment (already set, but ensure it's exported) + export HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy + + # Install custom CA certificate into the system trust store (for MITM proxies) + PLUGIN_WS_DIR_EARLY="$(detect_plugin_workspace)" + [ -z "$PLUGIN_WS_DIR_EARLY" ] && PLUGIN_WS_DIR_EARLY="/workspaces/netbox-librenms-plugin" + CA_BUNDLE_SRC="$PLUGIN_WS_DIR_EARLY/ca-bundle.crt" + if [ -f "$CA_BUNDLE_SRC" ]; then + echo "πŸ” Installing custom CA certificate into system trust store..." + cert_count=$(grep -c '-----BEGIN CERTIFICATE-----' "$CA_BUNDLE_SRC" 2>/dev/null || true) + if [ "${cert_count:-0}" -eq 0 ]; then + echo " ⚠️ ca-bundle.crt does not contain any PEM certificate blocks; skipping CA install." + else + mkdir -p /usr/local/share/ca-certificates/proxy + # Remove stale split fragments so they don't accumulate across rebuilds + find /usr/local/share/ca-certificates/proxy -maxdepth 1 -name 'cert-*' -delete 2>/dev/null || true + # Split the bundle into individual certs β€” update-ca-certificates needs one + # cert per file and skips non-CA leaf certs, so extract each PEM block as + # a separate .crt file. + csplit -z -f /usr/local/share/ca-certificates/proxy/cert- \ + "$CA_BUNDLE_SRC" '/-----BEGIN CERTIFICATE-----/' '{*}' \ + >/dev/null 2>&1 + CSPLIT_STATUS=$? + if [ "$CSPLIT_STATUS" -ne 0 ]; then + echo " ⚠️ Failed to split ca-bundle.crt (csplit exit code: $CSPLIT_STATUS). Skipping CA install." + elif compgen -G "/usr/local/share/ca-certificates/proxy/cert-*" > /dev/null; then + # Rename split fragments to .crt + for f in /usr/local/share/ca-certificates/proxy/cert-*; do + mv "$f" "${f}.crt" 2>/dev/null || true + done + update-ca-certificates 2>/dev/null + echo " βœ“ CA certificate installed into system trust store ($cert_count cert(s))" + else + echo " ⚠️ No certificate fragments were generated from ca-bundle.crt; skipping CA install." + fi + fi + # Point environment variables to the system bundle + export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt + # Configure pip globally so isolated virtualenvs (e.g. pre-commit) also + # use the system CA bundle instead of their bundled certifi. + pip config set global.cert /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + else + echo " ℹ️ No ca-bundle.crt found at $CA_BUNDLE_SRC, skipping CA install" + # Only disable git SSL verification if explicitly opted-in via ALLOW_GIT_SSL_DISABLE. + # Silently disabling SSL is a security risk; prefer providing a CA bundle instead. + if [ "${ALLOW_GIT_SSL_DISABLE:-false}" = "true" ]; then + git config --global http.sslVerify false + echo " ⚠️ git SSL verification disabled globally (ALLOW_GIT_SSL_DISABLE=true)" + else + echo " ⚠️ No CA bundle found and git SSL verification was NOT disabled." + echo " If you need to disable it, set ALLOW_GIT_SSL_DISABLE=true in .devcontainer/.env" + echo " Preferred: provide a ca-bundle.crt in the workspace root instead." + fi + fi +fi + # Verify NetBox virtual environment exists if [ ! -f "/opt/netbox/venv/bin/activate" ]; then echo "❌ NetBox virtual environment not found at /opt/netbox/venv/" @@ -27,23 +123,37 @@ fi # Install dev tools echo "πŸ”§ Installing development dependencies..." apt-get update -qq -apt-get install -y -qq net-tools +apt-get install -y -qq net-tools git $PIP_CMD install pytest pytest-django ruff pre-commit -# Detect plugin workspace directory (must contain pyproject.toml) -if [ -f "$PWD/pyproject.toml" ]; then - PLUGIN_WS_DIR="$PWD" -elif [ -d "/workspaces/netbox-librenms-plugin" ] && [ -f "/workspaces/netbox-librenms-plugin/pyproject.toml" ]; then - PLUGIN_WS_DIR="/workspaces/netbox-librenms-plugin" -else - CANDIDATE_DIR=$(find /workspaces -maxdepth 2 -type f -name pyproject.toml 2>/dev/null | head -n1 | xargs dirname || true) - if [ -n "$CANDIDATE_DIR" ] && [ -f "$CANDIDATE_DIR/pyproject.toml" ]; then - PLUGIN_WS_DIR="$CANDIDATE_DIR" - else - echo "❌ Could not locate plugin workspace directory (pyproject.toml not found)." - echo " Checked: $PWD and /workspaces/*" - exit 1 - fi +# Install GitHub CLI (gh) +# NOTE: The chained && commands below mean a partial failure (e.g. wget succeeds +# but apt-get install gh fails) may leave artifacts (keyring, sources list, temp +# file). This is acceptable here because it only runs during container build β€” +# a rebuild will retry from scratch. If this block is ever moved to a runtime +# script, consider adding a trap or explicit cleanup on error. +if ! command -v gh >/dev/null 2>&1; then + echo "πŸ”§ Installing GitHub CLI..." + (type -p wget >/dev/null || apt-get install -y -qq wget) \ + && install -d -m 755 /etc/apt/keyrings \ + && out=$(mktemp) \ + && wget -qO "$out" https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat "$out" | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update -qq \ + && apt-get install -y -qq gh \ + && rm -f "$out" \ + && echo " βœ“ GitHub CLI installed: $(gh --version | head -1)" \ + || echo "⚠️ GitHub CLI installation failed (non-fatal)" +fi + +# Detect plugin workspace directory using the shared helper +PLUGIN_WS_DIR="$(detect_plugin_workspace)" +if [ -z "$PLUGIN_WS_DIR" ]; then + echo "❌ Could not locate plugin workspace directory (pyproject.toml not found)." + echo " Checked: $PWD and /workspaces/*" + exit 1 fi echo "πŸ“‚ Plugin workspace: $PLUGIN_WS_DIR" @@ -160,42 +270,27 @@ python manage.py collectstatic --noinput >/dev/null 2>&1 || true # Set up pre-commit hooks echo "πŸͺ Installing pre-commit hooks..." cd "$PLUGIN_WS_DIR" +git config --global --add safe.directory "$PLUGIN_WS_DIR" pre-commit install --install-hooks 2>/dev/null || echo "⚠️ Pre-commit hook installation failed (may already be installed)" # Ensure scripts are executable chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/start-netbox.sh" || true chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/diagnose.sh" || true +chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/load-aliases.sh" || true -# Aliases for convenience -cat >> ~/.bashrc << EOF -# NetBox LibreNMS Plugin Development Aliases -export PATH="/opt/netbox/venv/bin:\$PATH" -export DEBUG="\${DEBUG:-True}" -PLUGIN_DIR="$PLUGIN_WS_DIR" -alias netbox-run-bg="\$PLUGIN_DIR/.devcontainer/scripts/start-netbox.sh --background" -alias netbox-run="\$PLUGIN_DIR/.devcontainer/scripts/start-netbox.sh" -alias netbox-restart="netbox-stop && sleep 1 && netbox-run-bg" -alias netbox-reload="cd \$PLUGIN_DIR && (command -v uv >/dev/null 2>&1 && uv pip install -e . || pip install -e .) && netbox-restart" -alias netbox-stop="([ -f /tmp/netbox.pid ] && kill \\\$(cat /tmp/netbox.pid) && rm /tmp/netbox.pid && echo 'NetBox stopped' || echo 'NetBox not running'); ([ -f /tmp/rqworker.pid ] && kill \\\$(cat /tmp/rqworker.pid) && rm /tmp/rqworker.pid && echo 'RQ worker stopped' || echo 'RQ worker not running')" -alias netbox-logs="tail -f /tmp/netbox.log" -alias netbox-status="[ -f /tmp/netbox.pid ] && kill -0 \\\$(cat /tmp/netbox.pid) 2>/dev/null && echo 'NetBox is running (PID: '\\\$(cat /tmp/netbox.pid)')' || echo 'NetBox is not running'; [ -f /tmp/rqworker.pid ] && kill -0 \\\$(cat /tmp/rqworker.pid) 2>/dev/null && echo 'RQ worker is running (PID: '\\\$(cat /tmp/rqworker.pid)')' || echo 'RQ worker is not running'" -alias rq-logs="tail -f /tmp/rqworker.log" -alias rq-status="[ -f /tmp/rqworker.pid ] && kill -0 \\\$(cat /tmp/rqworker.pid) 2>/dev/null && echo 'RQ worker is running (PID: '\\\$(cat /tmp/rqworker.pid)')' || echo 'RQ worker is not running'" -alias netbox-shell="cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell" -alias netbox-test="cd \$PLUGIN_DIR && source /opt/netbox/venv/bin/activate && python -m pytest" -alias netbox-manage="cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py" -alias plugin-install="cd \$PLUGIN_DIR && (command -v uv >/dev/null 2>&1 && uv pip install -e . || pip install -e .)" -alias ruff-check="cd \$PLUGIN_DIR && ruff check ." -alias ruff-format="cd \$PLUGIN_DIR && ruff format ." -alias ruff-fix="cd \$PLUGIN_DIR && ruff check --fix ." -alias diagnose="\$PLUGIN_DIR/.devcontainer/scripts/diagnose.sh" -alias plugins-install='if [ -f "$PLUGIN_DIR/.devcontainer/extra-requirements.txt" ]; then source /opt/netbox/venv/bin/activate && pip install -r "$PLUGIN_DIR/.devcontainer/extra-requirements.txt"; else echo "No .devcontainer/extra-requirements.txt found"; fi' -# Help -alias dev-help='echo "🎯 NetBox LibreNMS Plugin Development Commands:"; echo ""; echo "πŸ“Š NetBox Server Management:"; echo " netbox-run-bg : Start NetBox in background"; echo " netbox-run : Start NetBox in foreground (for debugging)"; echo " netbox-stop : Stop NetBox and RQ worker"; echo " netbox-restart : Restart NetBox and RQ worker"; echo " netbox-reload : Reinstall plugin and restart NetBox"; echo " netbox-status : Check if NetBox and RQ worker are running"; echo " netbox-logs : View NetBox server logs"; echo ""; echo "βš™οΈ Background Jobs (RQ Worker):"; echo " rq-status : Check if RQ worker is running"; echo " rq-logs : View RQ worker logs"; echo ""; echo "πŸ› οΈ Development Tools:"; echo " netbox-shell : Open NetBox Django shell"; echo " netbox-test : Run plugin tests"; echo " netbox-manage : Run Django management commands"; echo " plugin-install : Reinstall plugin in development mode"; echo ""; echo "🧹 Code Quality:"; echo " ruff-check : Check code with Ruff"; echo " ruff-format : Format code with Ruff"; echo " ruff-fix : Auto-fix code issues with Ruff"; echo ""; echo "πŸ”Ž Diagnostics:"; echo " diagnose : Run startup diagnostics"; echo " dev-help : Show this help message"; echo ""; echo "πŸ“– NetBox available at: http://localhost:8000 (admin/admin)"; echo ""' +# Load aliases and welcome message from the canonical source (load-aliases.sh). +# Appended to .bashrc so every interactive shell gets them automatically. +# Guard with a sentinel so rerunning setup.sh doesn't create duplicate entries. +BASHRC_SENTINEL="# NetBox LibreNMS Plugin β€” source aliases from the single canonical file" +if ! grep -qF "$BASHRC_SENTINEL" ~/.bashrc 2>/dev/null; then + cat >> ~/.bashrc << EOF +$BASHRC_SENTINEL +source "$PLUGIN_WS_DIR/.devcontainer/scripts/load-aliases.sh" # Show welcome message for new terminals -bash $PLUGIN_DIR/.devcontainer/scripts/welcome.sh +bash "$PLUGIN_WS_DIR/.devcontainer/scripts/welcome.sh" EOF +fi # Fix Git remote URLs for dev container compatibility echo "πŸ”§ Checking Git remote configuration..." @@ -221,4 +316,5 @@ else echo "⚠️ Warning: Plugin may not be properly installed" fi +echo "" echo "πŸš€ NetBox LibreNMS Plugin Dev Environment Ready!" diff --git a/.devcontainer/scripts/start-netbox.sh b/.devcontainer/scripts/start-netbox.sh index 2a02a336ed..0ab8acaf46 100755 --- a/.devcontainer/scripts/start-netbox.sh +++ b/.devcontainer/scripts/start-netbox.sh @@ -95,4 +95,4 @@ else echo "πŸ’‘ If clicking the URL opens 0.0.0.0:8000, manually type: localhost:8000" echo "" python manage.py runserver 0.0.0.0:8000 -fi \ No newline at end of file +fi diff --git a/.devcontainer/scripts/welcome.sh b/.devcontainer/scripts/welcome.sh index c2776a398d..3d972d5753 100755 --- a/.devcontainer/scripts/welcome.sh +++ b/.devcontainer/scripts/welcome.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Ensure aliases are available in the postAttach terminal session +source "$(dirname "$0")/load-aliases.sh" 2>/dev/null + echo "" echo "🎯 NetBox LibreNMS Plugin Development Environment" @@ -46,6 +49,7 @@ fi echo "" echo "πŸš€ Quick start:" echo " β€’ Type 'netbox-run' to start the development server" +echo " β€’ Type 'netbox-restart' to restart NetBox (after config changes)" echo " β€’ Type 'dev-help' to see all available commands" echo " β€’ Edit code in the workspace - auto-reload is enabled" -echo "" \ No newline at end of file +echo "" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f1d53cbd84..b64357a8fe 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,19 +3,28 @@ > **Note:** Additional context-specific instructions are in `.github/instructions/`: > - [testing.instructions.md](instructions/testing.instructions.md) – applies to `tests/**` > - [frontend.instructions.md](instructions/frontend.instructions.md) – applies to templates and static files -> - [background-jobs.instructions.md](instructions/background-jobs.instructions.md) – applies to `jobs.py` and import views +> - [background-jobs.instructions.md](instructions/background-jobs.instructions.md) – applies to `jobs.py`, import views, and import utilities +> - [sync.instructions.md](instructions/sync.instructions.md) – applies to sync views, base views, tables, and sync JS ## Architecture & Key Modules - Plugin hooks into NetBox (Django 5) under `netbox_librenms_plugin/`; respect NetBox plugin APIs (`navigation.py`, `urls.py`, `api/`). - LibreNMS communication lives in `librenms_api.py`; reuse this client instead of new `requests` calls. It handles multi-server configs via `LibreNMSSettings` model and the `servers` plugin config, plus caching via Django cache + custom fields. -- Views follow layered structure: resource views in `views/`, shared logic in `views/base/` and `views/mixins.py`. New views should extend the closest base class and compose mixins (e.g., `LibreNMSAPIMixin`, `CacheMixin`). -- Tables drive most UIs (`tables/*.py`). They emit HTMX-enabled columns and buttons, so prefer updating the table renderer rather than templates when changing row actions. -- Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. Sync pages extend `librenms_sync_base.html`. +- Views follow a three-layer structure: + - **Base views** (`views/base/`) β€” abstract views for each sync resource (`BaseInterfaceTableView`, `BaseCableTableView`, `BaseIPAddressTableView`, `BaseVLANTableView`). + - **Object sync views** (`views/object_sync/`) β€” concrete per-model views registered as tabs on NetBox's Device/VM detail pages via `@register_model_view(Device, ...)`. These wire base views to models. + - **Sync action views** (`views/sync/`) β€” POST-only views that apply changes (add/change/delete NetBox objects). Includes `interfaces.py`, `cables.py`, `ip_addresses.py`, `vlans.py`, `devices.py`, `device_fields.py`, `locations.py`. + - **Shared mixins** (`views/mixins.py`) β€” `LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`. +- All four sync resources (interfaces, cables, IP addresses, VLANs) follow the same three-layer pattern. VLAN sync additionally uses `VlanAssignmentMixin` for VLAN group scope resolution (Rack β†’ Location β†’ Site β†’ SiteGroup β†’ Region β†’ Global). +- New views should extend the closest base class and compose mixins. +- Tables (`tables/*.py`) and templates (`templates/netbox_librenms_plugin/`) drive the UI. See `frontend.instructions.md` for HTMX, template, and styling conventions. +- Forms (`forms.py`) include dynamic LibreNMS API-populated choices (location dropdowns, poller groups) and a split-form pattern for settings (server config form + import settings form). +- `import_validation_helpers.py` centralizes validation state mutation during import (role/cluster/rack assignment, issue removal, status recalculation). ## Data & Sync Conventions - Devices/VMs map to LibreNMS via the `librenms_id` custom field, then cached if absent. Always call `LibreNMSAPI.get_librenms_id` instead of touching the field directly. - Matching is intentionally **exact-only** for site, platform, device type, and role. See `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`). Do not add fuzzy matching. - Sync pipelines generally fetch LibreNMS data (`librenms_api.py`), cache it (`CacheMixin`), build comparison tables (`tables/`), and render HTMX fragments (`templates/netbox_librenms_plugin/htmx/`). Follow that flow for new resources. +- Virtual chassis support uses `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection. ## Developer Workflow - Prefer the devcontainer commands (`netbox-run`, `netbox-run-bg`, `netbox-reload`, `netbox-logs`) described in `.devcontainer/README.md`. They manage NetBox + plugin reloading. @@ -26,7 +35,41 @@ - API serializers (`api/serializers.py`) mirror models for external consumption. Update serializers and `api/views.py` together to avoid contract drift. - Navigation and menu items are registered in `navigation.py`; extend there for new sections so NetBox renders links correctly. +## Permission System +- Uses two-tier permissions via `LibreNMSSettings` model: `view_librenmssettings` (read) and `change_librenmssettings` (write). See `docs/development/permissions.md`. +- Permission constants in `constants.py`: `PERM_VIEW_PLUGIN` and `PERM_CHANGE_PLUGIN`. + +### Plugin-Level Permissions +- All views inherit `LibreNMSPermissionMixin` from `views/mixins.py`, which sets `permission_required = PERM_VIEW_PLUGIN` and provides: + - `has_write_permission()` β€” checks `PERM_CHANGE_PLUGIN`. + - `require_write_permission()` β€” returns error response (HTMX `HX-Redirect` or standard redirect) if denied. + - `require_write_permission_json()` β€” returns `JsonResponse(403)` if denied (for AJAX endpoints). + +### Object-Level Permissions +- `NetBoxObjectPermissionMixin` adds a **second layer** of permission checking for NetBox model operations (add/change/delete on Device, Interface, VLAN, etc.). +- Views declare `required_object_permissions` dict mapping HTTP methods to `[(action, Model)]` tuples, e.g.: + ```python + required_object_permissions = {"POST": [("add", VLAN), ("change", VLAN)]} + ``` +- Some views set `required_object_permissions` dynamically per-request (e.g., `SyncInterfacesView` switches between `Interface` and `VMInterface` based on object type). +- Provides: + - `check_object_permissions(method)` β†’ `(bool, missing_perms_list)` + - `require_object_permissions(method)` β€” redirect/HTMX on failure. + - `require_object_permissions_json(method)` β€” JSON 403 on failure. + - `require_all_permissions(method)` β€” combined plugin write + object perms check (redirect/HTMX). + - `require_all_permissions_json(method)` β€” combined check, JSON variant. +- **Sync POST handlers** must call `require_all_permissions("POST")` (not just `require_write_permission()`) and return early if it returns a response. AJAX/JSON endpoints use `require_all_permissions_json("POST")`. +- `_get_safe_redirect_url(request)` validates referrer URLs to prevent open-redirect attacks. + +### Permission Helpers for Background Jobs +- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details. + +### API & Navigation Permissions +- API endpoints use `LibreNMSPluginPermission` class in `api/views.py` (GET=view, others=change). +- Navigation menu (`navigation.py`) has 3 groups: **Settings** (Plugin Settings, Interface Mappings), **Import** (LibreNMS Import), **Status Check** (Site & Location Sync, Device Status, VM Status). All items use `permissions=[PERM_VIEW_PLUGIN]`. +- **Background job polling requires superuser** β€” non-superusers fall back to synchronous mode. See `background-jobs.instructions.md` for details. + ## When in Doubt - Check docs in `docs/development/` for structure, view inheritance, mixins, and template conventions before introducing new patterns. - Review the existing sync views (e.g., `views/sync/interfaces.py`) as reference implementations for data flow and caching patterns. -- Coordinate any schema changes through Django migrations in `migrations/` and update `models.py` + admin/pydantic representations accordingly. \ No newline at end of file +- Coordinate any schema changes through Django migrations in `migrations/` and update `models.py` + admin/pydantic representations accordingly. diff --git a/.github/instructions/background-jobs.instructions.md b/.github/instructions/background-jobs.instructions.md index 2fcb4be068..405d9e0380 100644 --- a/.github/instructions/background-jobs.instructions.md +++ b/.github/instructions/background-jobs.instructions.md @@ -1,9 +1,9 @@ --- -applyTo: "**/jobs.py,**/views/imports/**" -description: Background job architecture and task management patterns +applyTo: "**/jobs.py,**/views/imports/**,**/import_utils.py,**/import_validation_helpers.py" +description: Background job architecture, import workflow, and task management patterns --- -# Background Jobs & Task Management +# Background Jobs & Import Workflow ## Job Architecture - Background jobs use NetBox's `JobRunner` base class (`netbox.jobs.JobRunner`) for long-running operations like device filtering with VC detection. @@ -27,5 +27,58 @@ description: Background job architecture and task management patterns - Handle all RQ status values explicitly to avoid infinite polling - Use `cancelInProgress` flag to prevent polling interference during cancellation +## Superuser Requirement for Background Jobs +- NetBox's `/api/core/background-tasks/` endpoint requires **superuser** (`IsSuperuser` in `BaseRQViewSet`). +- Non-superuser users cannot poll job status; they get 403 Forbidden. +- The plugin automatically falls back to synchronous mode for non-superusersβ€”see `should_use_background_job()` in `list.py` and `actions.py`. +- This is a NetBox core design decision, not a plugin limitation. No amount of permissions (including `core.view_job`) bypasses it. + +## Import Jobs +- **`FilterDevicesJob`** β€” background device filtering with VC detection. `job.data` keys: `device_ids`, `total_processed`, `filters`, `server_key`, `vc_detection_enabled`, `cache_timeout`, `cached_at`, `completed`. Devices are cached individually via shared cache keys from `get_validated_device_cache_key()`. +- **`ImportDevicesJob`** β€” background device/VM import. Calls `bulk_import_devices_shared()` for devices and `bulk_import_vms()` for VMs. `job.data` keys: `imported_device_pks`, `imported_vm_pks`, `imported_libre_device_ids`, `imported_libre_vm_ids`, `server_key`, `total`, `success_count`, `failed_count`, `skipped_count`, `virtual_chassis_created`, `errors`, `completed`. + +## Shared Cache Key Pattern +- Both synchronous and background modes use `get_validated_device_cache_key()` from `import_utils.py` to generate cache keys. This ensures `_load_job_results()` in the list view can retrieve devices regardless of which mode produced them. +- `get_active_cached_searches()` manages multi-search cache to let users run and switch between searches. +- Never hardcode cache key formats; always use the helper functions. + +## Permission Checks in Jobs +- Background jobs run outside view context, so they cannot use view mixins. +- Use standalone helpers from `import_utils.py` for permission checks inside job code: + - `check_user_permissions(user, permissions)` β†’ `(bool, missing_list)` + - `require_permissions(user, permissions, action_description)` β€” raises `PermissionDenied`. + ## Custom Sync Endpoint `api/views.py::sync_job_status()` syncs database Job status with RQ job status, needed because NetBox worker doesn't always update DB when jobs stop before processing starts. + +## Import Page Flow +The import page (`LibreNMSImportView` in `views/imports/list.py`) supports two modes: + +1. **Synchronous** β€” calls `process_device_filters()` directly, renders results inline. +2. **Background** β€” enqueues `FilterDevicesJob`, returns `JsonResponse` with `job_id`/`job_pk`/`poll_url`. Frontend polls and redirects to `?job_id={pk}` on completion. + +Result loading: `_load_job_results(job_id)` reads `job.data["device_ids"]`, reconstructs devices from per-device cache using `get_validated_device_cache_key()`. + +Filter fields: `librenms_location`, `librenms_type`, `librenms_os`, `librenms_hostname`, `librenms_sysname`, `librenms_hardware`, `enable_vc_detection`, `show_disabled`, `exclude_existing`. + +## Import Action Views (`views/imports/actions.py`) +- **`DeviceImportHelperMixin`** β€” provides `get_validated_device_with_selections()` and `render_device_row()` for HTMX row rendering. Shared by update views. +- **`BulkImportConfirmView`** (POST) β€” renders confirmation modal with selected device list. Returns `htmx/bulk_import_confirm.html`. +- **`BulkImportDevicesView`** (POST) β€” executes import. Background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`. +- **`DeviceValidationDetailsView`** (GET) β€” renders expandable validation details via `htmx/device_validation_details.html`. +- **`DeviceVCDetailsView`** (GET) β€” renders VC member details via `htmx/device_vc_details.html`. +- **`DeviceRoleUpdateView`**, **`DeviceClusterUpdateView`**, **`DeviceRackUpdateView`** (POST) β€” per-device dropdown updates. Apply selection to validation state and return re-rendered row via `render_device_row()`. + +## Key Import Utilities (`import_utils.py`) +- `process_device_filters(filters, ...)` β€” fetches and validates devices from LibreNMS, returns list. +- `validate_device_for_import(device, ...)` β€” core validation function, produces validation state dict. +- `bulk_import_devices_shared(devices, user, ...)` β€” shared implementation between sync and background import. +- `bulk_import_vms(vm_imports, user, ...)` β€” VM import implementation. +- `fetch_device_with_cache(device_id, ...)` β€” retrieves/caches individual device data. +- Cache key functions: `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`. + +## Validation Helpers (`import_validation_helpers.py`) +Centralizes validation state mutation used by the role/cluster/rack update views: +- `apply_role_to_validation()`, `apply_cluster_to_validation()`, `apply_rack_to_validation()` β€” update validation state when user selects a role/cluster/rack. +- `remove_validation_issue()`, `recalculate_validation_status()` β€” maintain issue list and overall status. +- `fetch_model_by_id()`, `extract_device_selections()` β€” helpers for reading form data. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 7fd943e871..4f02754b5b 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -8,12 +8,18 @@ description: Frontend patterns for templates, HTMX, and static assets ## HTMX Conventions - HTMX 2.x is the primary async layer. Table row updates return ``. - Avoid `outerHTML` swaps; use OOB or targeted `innerHTML` swaps to keep table layout intact. -- HTMX fragments live in `templates/netbox_librenms_plugin/htmx/`. Keep server responses and HTMX targets in sync. +- All HTMX requests and `fetch()` calls must include a CSRF token. The standard pattern is `document.querySelector('[name=csrfmiddlewaretoken]').value` (from a hidden form input). The import JS also uses `getCookie('csrftoken')` as a fallback β€” prefer the hidden input approach for consistency. ## Modal Implementation - Modals use Tabler (Bootstrap-like) but **without** `bootstrap.Modal` helpers. - Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. - Do not reintroduce `data-bs-toggle` or duplicate modal IDs. +- The import page uses `ModalManager` class and `filterModalManager` instanceβ€”always use this reference in fetch callbacks, not undefined `modalInstance` variables. + +## JavaScript Fetch Patterns +- Always check `response.ok` before processing fetch responses to catch HTTP errors. +- In catch blocks, show `error.message` for debugging rather than generic messages. +- The import filter form uses fetch with `Accept: application/json, text/html`β€”JSON for background jobs, HTML for synchronous mode. ## Form Controls - Device import dropdowns rely on TomSelect decorators set up elsewhere. @@ -26,4 +32,35 @@ description: Frontend patterns for templates, HTMX, and static assets ## Template Structure - Templates live in `templates/netbox_librenms_plugin/`; reuse/includes under `inc/`. - Sync pages extend `librenms_sync_base.html`. -- Tables drive most UIs (`tables/*.py`). They emit HTMX-enabled columns and buttons, so prefer updating the table renderer in Python rather than templates when changing row actions. +- Tables emit HTMX-enabled columns and buttons (`tables/*.py`), so prefer updating the table renderer in Python rather than templates when changing row actions. + +## Sync Tab Template Pattern +- Each sync resource has two templates following a naming convention: + - `__sync.html` β€” the tab wrapper, loaded once when the tab is selected. + - `__sync_content.html` β€” the HTMX-swappable inner fragment, refreshed on data changes without a full page reload. +- Current resources: `_interface_sync`, `_cable_sync`, `_ipaddress_sync`, `_vlan_sync`. +- When adding a new sync resource, create both the wrapper and content templates following this pattern. + +## HTMX Fragments +- HTMX fragments live in `templates/netbox_librenms_plugin/htmx/` and include: + - `device_import_row.html` β€” individual import row updates. + - `device_validation_details.html` β€” expandable validation details. + - `device_vc_details.html` β€” virtual chassis member details. + - `bulk_import_confirm.html` β€” import confirmation modal content. +- Keep server responses and HTMX targets in sync when modifying these fragments. + +## Settings Page +- `settings.html` uses a split-form pattern: two separate Django forms (`ServerConfigForm` + `ImportSettingsForm`) sharing one page, differentiated by a hidden `form_type` field (`"server_config"` or `"import_settings"`). +- The test-connection button is an HTMX POST to `TestLibreNMSConnectionView`, returning an inline alert fragment. + +## Paginator +- `inc/paginator.html` is a custom paginator that preserves tab state and `interface_name_field` in pagination URLs. Used across all sync tables. + +## Import Page JavaScript (`librenms_import.js`) +- Wrapped in an IIFE with `window.LibreNMSImportInitialized` guard to prevent re-initialization during HTMX swaps. +- **`ModalManager`** class wraps Bootstrap 5 modal show/hide with fallback. +- **`pollJobStatus()`** β€” polls `/api/core/background-tasks/{jobId}/` every 2s, updates progress messages, handles cancel button, redirects on completion. +- **`captureSelectionState()` / `restoreSelectionState()`** β€” preserves checkbox state across HTMX content swaps. +- **`createCacheCountdown()`** β€” generic countdown timer for cache expiration display. +- **`initializeFilterForm()`** β€” intercepts form submit, detects JSON response (background job), starts polling. +- CSRF token extracted via `getCookie('csrftoken')` (cookie-based). diff --git a/.github/instructions/sync.instructions.md b/.github/instructions/sync.instructions.md new file mode 100644 index 0000000000..5ab67be719 --- /dev/null +++ b/.github/instructions/sync.instructions.md @@ -0,0 +1,72 @@ +--- +applyTo: "**/views/base/**,**/views/object_sync/**,**/views/sync/**,**/tables/**,**/librenms_sync.js" +description: Sync page architecture, base views, and sync action patterns +--- + +# Sync Pages + +## Three-Layer View Architecture +All four sync resources (interfaces, cables, IP addresses, VLANs) follow the same pattern: + +1. **Base views** (`views/base/`) β€” abstract classes that define the data pipeline: + - `BaseLibreNMSSyncView` β€” tabbed sync page, orchestrates all tabs via abstract `get_*_context()` methods. + - `BaseInterfaceTableView` β€” fetch ports β†’ enrich with VLANs β†’ cache β†’ compare with NetBox interfaces β†’ render table. + - `BaseCableTableView` β€” fetch links β†’ match remote devices β†’ check cable status β†’ render table. + - `BaseIPAddressTableView` β€” fetch IPs β†’ resolve interfaces β†’ detect existing/update/new β†’ render table. + - `BaseVLANTableView` β€” fetch VLANs β†’ compare with NetBox VLANs β†’ auto-select groups β†’ render table. + +2. **Object sync views** (`views/object_sync/`) β€” wire base views to NetBox models: + - Use `@register_model_view(Device, name="librenms_sync", path="librenms-sync")` to inject as a tab on Device/VM detail pages. + - Each `get_*_context()` method creates an instance of the concrete table view, copies `request`, and calls `get_context_data()`. + - VMs skip cables and VLANs (return `None`). + +3. **Sync action views** (`views/sync/`) β€” POST-only views that create/update/delete NetBox objects: + - Follow a consistent pattern: check permissions β†’ read selected rows from POST β†’ load cached data β†’ apply changes in `transaction.atomic()` β†’ redirect to sync tab. + +## Data Pipeline (Base Views) +Every base table view follows: **fetch β†’ cache β†’ compare β†’ render**. + +- **Fetch:** Call LibreNMS API (e.g., `get_ports()`, `get_device_ips()`, `get_device_vlans()`). +- **Cache:** Store results via `CacheMixin` keys: `librenms_{data_type}_{model_name}_{pk}`. Also store fetch timestamp at `librenms_{data_type}_last_fetched_{model_name}_{pk}`. +- **Compare:** Match LibreNMS data against NetBox objects. Each resource implements its own comparison (interface matching by name, IP matching by address/mask, VLAN matching by VID+group). +- **Render:** Build a django-tables2 table, return a partial template (`_*_sync_content.html`). + +## Sync Action View Pattern +```python +class SyncSomeResourceView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): + required_object_permissions = {"POST": [("add", Model), ("change", Model)]} + + def post(self, request, object_type, object_id): + if error := self.require_all_permissions("POST"): + return error + # 1. Resolve object (Device or VM) + # 2. Read selected items from request.POST.getlist("select") + # 3. Load cached data from cache.get(self.get_cache_key(obj, "...")) + # 4. Apply changes inside transaction.atomic() + # 5. Redirect to sync tab with ?tab= +``` + +## Table Conventions (`tables/*.py`) +- Tables define HTMX-enabled columns and checkboxes. Selection uses `ToggleColumn(attrs={"input": {"name": "select"}})`. +- Constructor takes contextual params (e.g., `device`, `interface_name_field`, `vlan_groups`) to customize rendering. +- Tables set `self.tab` and `self.prefix` for multi-table pagination via `get_table_paginate_count()`. +- Row attrs include `data-*` attributes for JavaScript filtering and identification. +- VLAN columns use `render_vlans()` with hidden inputs for per-row group selection and JSON data for modals. + +## Key Mixins Used by Sync Views +- **`LibreNMSAPIMixin`** β€” lazy-creates `LibreNMSAPI` instance via `self.librenms_api` property. Also provides `get_server_info()` for template context. +- **`CacheMixin`** β€” generates consistent cache keys via `get_cache_key(obj, data_type)` and `get_last_fetched_key(obj, data_type)`. Also provides `get_vlan_overrides_key(obj)` for VLAN group override persistence. +- **`VlanAssignmentMixin`** β€” VLAN group scope resolution: Rack β†’ Location β†’ Site β†’ SiteGroup β†’ Region β†’ Global. Used by interface and VLAN sync for auto-selecting the most-specific VLAN group and building lookup maps. + +## JavaScript (`librenms_sync.js`) +- Not wrapped in an IIFE β€” functions are global. Master initializer `initializeScripts()` runs on `DOMContentLoaded` and `htmx:afterSwap`. +- **Key function groups:** + - Checkbox management: `initializeTableCheckboxes()`, `updateBulkActionButton()`. + - TomSelect dropdowns: `initializeVCMemberSelect()`, `initializeVRFSelects()`, `initializeVlanGroupSelects()`, `initializeVlanSyncGroupSelects()`. Uses `TOMSELECT_INIT_DELAY_MS = 100` for delayed initialization after HTMX swaps. + - Verification: `handleInterfaceChange()`, `handleCableChange()`, `handleVRFChange()` β€” POST to single-item verify endpoints. + - VLAN modals: `openVlanDetailModal()`, `verifyVlanInGroup()`, `verifyVlanSyncGroup()` β€” per-interface VLAN detail editing. + - Bulk operations: `initializeBulkEditApply()`, `deleteSelectedInterfaces()`. + - Table filtering: `initializeTableFilters()`, `filterTable()` β€” client-side row filtering. + - URL/tab state: `initializeTabs()`, `getDeviceIdFromUrl()`, `setInterfaceNameFieldFromURL()`. + - Cache countdowns: `initializeCountdown()`, `initializeCountdowns()`. +- CSRF token extracted via `document.querySelector('[name=csrfmiddlewaretoken]').value`. diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 82a82b2214..16b6e8858d 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -21,7 +21,30 @@ description: Testing patterns and conventions for the NetBox LibreNMS plugin - **Never use `RequestFactory`**β€”mock request objects directly or test method logic in isolation. - Cache key tests must patch `get_validated_device_cache_key` from `import_utils.py`; never hardcode key formats like `job_123_device_1`. +## Test File Naming +- Follow the `test_{module_name}.py` convention for new test files. +- `test_netbox_librenms_plugin.py` is an empty placeholder β€” do not add tests there. + ## Test Coverage by Module -- `librenms_api.py` β†’ `test_librenms_api.py` +- `librenms_api.py` β†’ `test_librenms_api.py`, `test_librenms_api_helpers.py` - `import_utils.py`, `import_validation_helpers.py`, `utils.py` β†’ `test_import_utils.py`, `test_import_validation_helpers.py`, `test_utils.py` - `jobs.py`, `views/imports/list.py` β†’ `test_background_jobs.py` +- Permission mixins, API permissions, constants β†’ `test_permissions.py` +- VLAN API, mode detection, comparison, sync β†’ `test_vlan_sync.py` +- `VlanAssignmentMixin`, VLAN enrichment β†’ `test_interface_vlan_sync.py` +- Views (`views/sync/`, `views/object_sync/`, `views/imports/actions.py`) β€” no dedicated test files yet. Test business logic via the utility modules they call, not via HTTP requests. + +## Permission Test Patterns +When testing permissions (see `test_permissions.py` for reference): +- Create a mock view instance with `object.__new__(ViewClass)`, set `request = MagicMock()` with `request.user.has_perm.side_effect = lambda p: p in allowed_perms`. +- For `NetBoxObjectPermissionMixin` tests, set `required_object_permissions` on the instance before calling `check_object_permissions()`. +- Test both the individual methods (`has_write_permission()`, `check_object_permissions()`) and the combined `require_all_permissions()` flow. +- For JSON variants (`require_all_permissions_json`), assert `isinstance(response, JsonResponse)` and check `response.status_code == 403`. + +## Shared Fixtures (`conftest.py`) +Reuse fixtures from `tests/conftest.py` instead of creating ad-hoc mocks: +- **Configuration**: `mock_multi_server_config`, `mock_legacy_config` +- **API client**: `mock_librenms_api` +- **NetBox objects**: `mock_netbox_device`, `mock_netbox_vm`, `mock_netbox_site`, `mock_netbox_platform`, `mock_netbox_device_type`, `mock_netbox_device_role`, `mock_netbox_cluster`, `mock_netbox_rack` +- **HTTP responses**: `mock_response_factory`, `mock_success_response`, `mock_device_response`, `mock_error_response`, `mock_auth_error_response` +- **Import workflow**: `sample_librenms_device`, `sample_librenms_device_minimal`, `sample_validation_state`, `sample_validation_state_vm` diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index 5829a14ce2..c5ba8432de 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -15,12 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.9' + cache: 'pip' - name: Install dependencies run: | @@ -28,14 +29,22 @@ jobs: pip install ruff - name: Run Ruff linting - run: ruff check . - continue-on-error: true + run: | + echo "::group::Ruff Linting" + ruff check . --output-format=github + echo "::endgroup::" - name: Run Ruff formatting check - run: ruff format --check . - continue-on-error: true + run: | + echo "::group::Ruff Formatting" + ruff format --check . + echo "::endgroup::" - name: Report formatting issues - if: always() + if: failure() run: | - echo "If there are any formatting issues, run 'ruff check --fix .' and 'ruff format .' locally and push the changes." + 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." diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index aa82b8bb26..26afa7d787 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -name: Tests +name: Test with all supported NetBox versions on: push: @@ -11,11 +11,12 @@ on: - develop jobs: - test: + test-netbox: runs-on: ubuntu-latest + strategy: matrix: - python-version: ['3.12', '3.13', '3.14'] + python-version: ["3.12", "3.13", "3.14"] services: redis: @@ -37,41 +38,41 @@ jobs: - 5432:5432 steps: - - name: Checkout plugin code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@main with: path: netbox-librenms-plugin - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@main with: python-version: ${{ matrix.python-version }} - name: Checkout NetBox - uses: actions/checkout@v4 + uses: actions/checkout@main with: - repository: netbox-community/netbox + repository: "netbox-community/netbox" path: netbox ref: main - - name: Install NetBox dependencies - working-directory: netbox - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Install plugin + - name: Install NetBox LibreNMS Plugin working-directory: netbox-librenms-plugin run: | - pip install . - pip install -r requirements_dev.txt + pip install -e . + pip install pytest pytest-django - - name: Configure NetBox - working-directory: netbox/netbox + - name: Set up configuration + working-directory: netbox run: | - cp netbox/configuration_testing.py netbox/configuration.py + ln -s $(pwd)/../netbox-librenms-plugin/media/configuration.testing.py netbox/netbox/configuration.py + + python -m pip install --upgrade pip + python -m pip install tblib + pip install -r requirements.txt -U - name: Run tests - working-directory: netbox + working-directory: netbox/netbox + 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 diff --git a/.gitignore b/.gitignore index 525045b674..538eb6f7e9 100644 --- a/.gitignore +++ b/.gitignore @@ -284,4 +284,8 @@ cython_debug/ .devcontainer/extra-requirements.txt .devcontainer/config/plugin-config.py .devcontainer/config/extra-configuration.py -.devcontainer/config/extra-plugins.py \ No newline at end of file +.devcontainer/config/extra-plugins.py +# Proxy CA certificates (keep local) +ca-bundle.crt +*.pem +.github/hooks/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ea62321cc..7d553d0af5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,4 +15,4 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - id: check-merge-conflict \ No newline at end of file + - id: check-merge-conflict diff --git a/LICENSE b/LICENSE index f49a4e16e6..261eeb9e9f 100644 --- a/LICENSE +++ b/LICENSE @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/README.md b/README.md index 2bb2e017c1..3f8961beff 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Pull interface data from Devices and Virtual Machines from LibreNMS into NetBox. - Speed - MTU - MAC Address +- VLAN (Tagged and untagged) > Set custom mappings for interface types to ensure that the correct interface type is used when syncing from LibreNMS to NetBox. @@ -47,6 +48,10 @@ Create cable connection in NetBox from LibreNMS links data. ### IP Address Sync Create IP address in NetBox from LibreNMS device IP data. +### VLAN Sync +- Create VLAN objects in NetBox from LibreNMS device VLAN data +- Per-VLAN group assignment with scope-aware auto-selection + ### Add device to LibreNMS from Netbox - Add device to LibreNMS from Netbox device page. SNMP v2c and v3 are supported. diff --git a/docs/README.md b/docs/README.md index a6ba17c076..3636997ed7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ Pull interface data from Devices and Virtual Machines from LibreNMS into NetBox. * Speed * MTU * MAC Address +* VLAN (Tagged and untagged) > Set custom mappings for interface types to ensure that the correct interface type is used when syncing from LibreNMS to NetBox. @@ -52,6 +53,10 @@ Create cable connection in NetBox from LibreNMS links data. Create IP address in NetBox from LibreNMS device IP data. +### VLAN Sync +- Create VLAN objects in NetBox from LibreNMS device VLAN data +- Per-VLAN group assignment with scope-aware auto-selection + ### Add device to LibreNMS from Netbox * Add device to LibreNMS from Netbox device page. SNMP v2c and v3 are supported. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b4e7cb41b5..2ea7de296d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -6,6 +6,7 @@ * [Initial Setup](usage_tips/README.md) * [Custom Field Setup](usage_tips/custom_field.md) * [Multi-Server Setup](usage_tips/multi_server_configuration.md) + * [Permissions & Access](usage_tips/permissions.md) * [Suggested Workflow](usage_tips/suggested_workflow.md) * [Import Devices](librenms_import/overview.md) * [Overview](librenms_import/overview.md) diff --git a/docs/development/README.md b/docs/development/README.md index d4faac47df..de81c7d02b 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -8,7 +8,3 @@ This guide is intended for developers and contributors working on the NetBox Lib - [Views & Inheritance](./views.md): How views are organized, inheritance patterns, and extension tips. - [Mixins](./mixins.md): Reusable logic for views, including API access and caching. - [Templates](./templates.md): Template structure, conventions, and customization tips. - - - - diff --git a/docs/development/mixins.md b/docs/development/mixins.md index 7762a5fd4c..7aa487c05d 100644 --- a/docs/development/mixins.md +++ b/docs/development/mixins.md @@ -12,11 +12,23 @@ Mixins in `views/mixins.py` provide reusable logic to keep views clean and DRY ( **CacheMixin** - - Supplies helper methods for generating cache keys related to objects and data types (e.g., ports, links). + - Supplies helper methods for generating cache keys related to objects and data types (e.g., ports, links, vlans). - Useful for views that cache data fetched from LibreNMS to improve performance. - Methods: - `get_cache_key(obj, data_type="ports")`: Returns a unique cache key for the object and data type. - `get_last_fetched_key(obj, data_type="ports")`: Returns a cache key for tracking when data was last fetched. + - `get_vlan_overrides_key(obj)`: Returns a cache key for storing user VLAN group override selections. + +**VlanAssignmentMixin** + + - Provides VLAN group resolution and assignment logic used by both the Interfaces tab (per-interface VLAN assignments) and the VLANs tab (VLAN object sync). + - Resolves which VLAN groups are relevant to a device based on a scope hierarchy: Rack β†’ Location β†’ Site β†’ SiteGroup β†’ Region β†’ Global. + - Methods: + - `get_vlan_groups_for_device(device)`: Returns all VLAN groups relevant to the device based on scope hierarchy. + - `_build_vlan_lookup_maps(vlan_groups)`: Builds lookup dictionaries mapping VIDs to groups, VLANs, and names. + - `_select_most_specific_group(groups, device)`: Resolves ambiguity when a VID exists in multiple groups by selecting the most specific scope. + - `_find_vlan_in_group(vid, vlan_group_id, lookup_maps)`: Finds a VLAN by VID, preferring the specified group. + - `_update_interface_vlan_assignment(interface, vlan_data, vlan_group_map, lookup_maps)`: Updates interface mode, untagged VLAN, and tagged VLANs in NetBox. ### How to Use Mixins @@ -30,4 +42,3 @@ class MyCustomView(LibreNMSAPIMixin, CacheMixin, SomeBaseView): ``` Mixins can be combined as needed. Place mixins before the main base view to ensure their methods and properties are available. - diff --git a/docs/development/structure.md b/docs/development/structure.md index f8538b1502..c3eea9d7ac 100644 --- a/docs/development/structure.md +++ b/docs/development/structure.md @@ -6,8 +6,9 @@ This document provides an overview of the NetBox LibreNMS Plugin's codebase orga - `netbox_librenms_plugin/` β€” Main plugin code - `views/` β€” Custom views for devices, mappings, VMs, etc. - - `base/` β€” Abstract base views for shared logic - - `sync/` β€” Views for synchronization logic + - `base/` β€” Abstract base views for shared logic (interfaces, cables, IP addresses, VLANs) + - `object_sync/` β€” Per-model sync views registered as tabs on Device/VM detail pages + - `sync/` β€” POST-only views that apply sync changes (interfaces, cables, IP addresses, VLANs, devices) - `models.py` β€” Database models - `forms.py` β€” Custom forms - `tables/` β€” Table definitions for UI @@ -23,5 +24,3 @@ This document provides an overview of the NetBox LibreNMS Plugin's codebase orga - `js/` β€” JavaScript files - `tests/` β€” Test suite - `docs/` β€” Documentation - - diff --git a/docs/development/templates.md b/docs/development/templates.md index 49dc700742..637d016d73 100644 --- a/docs/development/templates.md +++ b/docs/development/templates.md @@ -18,6 +18,7 @@ Templates are located in `templates/netbox_librenms_plugin/` and follow NetBox's - `librenms_sync_base.html` provides the main layout for device/VM sync pages, extending NetBox's object template and including custom blocks for status, actions, and content. - `_interface_sync.html` and `_interface_sync_content.html` are used for the interface sync tab, supporting dynamic updates and user actions (like syncing selected interfaces). + - `_vlan_sync.html` and `_vlan_sync_content.html` provide the VLAN sync tab (Devices only), with per-VLAN group selection dropdowns, color-coded status indicators (green/yellow/red), and a cache countdown timer. The VLANs tab is conditionally rendered only for devices in `librenms_sync_base.html`. **Mapping Views:** diff --git a/docs/development/testing.md b/docs/development/testing.md index 094fa7bd70..85505a1415 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -27,6 +27,8 @@ The test suite covers all major plugin functionality. Tests are organized by the | [test_import_validation_helpers.py](../../netbox_librenms_plugin/tests/test_import_validation_helpers.py) | Field validation for sites, roles, platforms, and device types | | [test_utils.py](../../netbox_librenms_plugin/tests/test_utils.py) | General utilitiesβ€”name matching, speed conversion, and data formatting | | [test_background_jobs.py](../../netbox_librenms_plugin/tests/test_background_jobs.py) | Background job execution and view decision logic | +| [test_vlan_sync.py](../../netbox_librenms_plugin/tests/test_vlan_sync.py) | VLAN syncβ€”API fetching, comparison logic, CSS class utilities, and sync actions | +| [test_interface_vlan_sync.py](../../netbox_librenms_plugin/tests/test_interface_vlan_sync.py) | Interface VLAN assignmentsβ€”group resolution, mode detection, and per-interface VLAN assignment | Supporting files: diff --git a/docs/development/views.md b/docs/development/views.md index 6ede3277a4..4e19d2b69b 100644 --- a/docs/development/views.md +++ b/docs/development/views.md @@ -11,7 +11,7 @@ Views are organized by resource type (e.g., devices, mappings, VMs) in the `view **Base views:** - - The `base/` subdirectory contains abstract base views (e.g., `BaseLibreNMSSyncView`, `BaseInterfaceTableView`) that encapsulate shared logic for related resources. + - The `base/` subdirectory contains abstract base views (e.g., `BaseLibreNMSSyncView`, `BaseInterfaceTableView`, `BaseCableTableView`, `BaseIPAddressTableView`, `BaseVLANTableView`) that encapsulate shared logic for related resources. **Mixins:** @@ -47,6 +47,20 @@ class DeviceInterfaceTableView(BaseInterfaceTableView): ... ``` +#### Example: VLAN Table View + +```python +from .base.vlan_table_view import BaseVLANTableView + +class DeviceVLANTableView(BaseVLANTableView): + model = Device + # Inherits VLAN comparison, group resolution, and caching from base view + # Only the model attribute needs to be set + ... +``` + +`BaseVLANTableView` additionally inherits `VlanAssignmentMixin` for VLAN group scope resolution. It fetches device VLANs from LibreNMS, compares them against NetBox VLAN objects across relevant VLAN groups, and renders a color-coded table with per-VLAN group dropdowns. + ### Customizing or Adding Views - To add a new view for a resource, inherit from the relevant base view and mixins, then override or extend methods as needed. diff --git a/docs/feature_list.md b/docs/feature_list.md index fd741c4afe..564e57a941 100644 --- a/docs/feature_list.md +++ b/docs/feature_list.md @@ -41,6 +41,7 @@ * Speed * MAC Address * MTU + * VLAN assignments * Sync all or specific fields ### Cable Sync {#cable-sync} @@ -53,6 +54,12 @@ * Create IP address objects in Netbox from LibreNMS device IP data * Best results when the [custom field](usage_tips/custom_field.md) `librenms_id` is populated on interfaces +### VLAN Sync {#vlan-sync} + +* Create VLAN objects in NetBox from LibreNMS device VLAN data +* Per-VLAN group assignment with scope-aware auto-selection + + ### Location * NetBox Site to LibreNMS location synchronization @@ -63,4 +70,3 @@ * Customizable LibreNMS to NetBox interface type mappings * Interface Speed-based mapping rules * Bulk import support - diff --git a/docs/librenms_import/import_process.md b/docs/librenms_import/import_process.md index e0dc4d92a2..e30e7acbbb 100644 --- a/docs/librenms_import/import_process.md +++ b/docs/librenms_import/import_process.md @@ -93,4 +93,3 @@ After importing devices, typical next steps include: 5. **Sync IP addresses** - Pull IP address assignments from LibreNMS to NetBox These sync operations use the `librenms_id` custom field that was automatically set during import. - diff --git a/docs/librenms_import/import_settings.md b/docs/librenms_import/import_settings.md index a70684081c..5c3f2c15ac 100644 --- a/docs/librenms_import/import_settings.md +++ b/docs/librenms_import/import_settings.md @@ -13,6 +13,19 @@ To configure global defaults for all imports: These defaults apply to all future imports unless overridden during the import process. +## User Preferences and Defaults + +The plugin uses a two-tier preference system for the **Use sysName** and **Strip Domain** toggles: + +1. **Plugin defaults** (set by admins on the Settings page) apply to all users who have not yet changed their own toggle settings. +2. **Per-user preferences** are saved automatically when a user changes a toggle on the import page. Once saved, the user's preference takes priority over the plugin default. + +**Important notes:** + +- Changing the plugin defaults does **not** override existing user preferences. Users who have previously changed a toggle keep their personal setting. +- When an admin saves import settings, only the admin's own preferences are updated to match the new defaults. Other users are unaffected. +- There is no "reset to defaults" for individual users. To revert to the plugin default, a user simply needs to toggle the setting to match. + ## Device Naming Options The plugin provides two settings that control how device names are created in NetBox. Both are configured in Plugin Settings under **Plugins β†’ LibreNMS Plugin β†’ Settings β†’ Plugin Settings** and can be overridden on the LibreNMS import page. @@ -49,11 +62,10 @@ If neither sysName nor hostname exists, the plugin generates a name as `device-{ ## Per-Import Overrides -When using bulk import, you can override the default settings in the confirmation modal before importing. This allows you to: +On the import page, the **Use sysName** and **Strip Domain** toggles are pre-populated from your saved preference (or the plugin default if you haven't set one). Changing a toggle immediately saves your preference for next time and applies to the current import. + +This allows you to: - Import some devices with sysName and others with hostname - Apply domain stripping selectively based on device type or location -- Test different naming conventions before changing global defaults - -The override only affects the current import operation and doesn't change your saved defaults. - +- Test different naming conventions β€” your last choice is remembered automatically diff --git a/docs/usage_tips/README.md b/docs/usage_tips/README.md index e8a64bd536..43527cf3f4 100644 --- a/docs/usage_tips/README.md +++ b/docs/usage_tips/README.md @@ -61,8 +61,9 @@ For best results, align chassis member positions with interface naming patterns. - Review interface mappings indicated by the icons (πŸ”— shows a mapping is configured) - Check speed and type matches - Confirm member assignments for virtual chassis -2. Exlude columns to exclude from interface sync +2. Exclude columns to exclude from interface sync - Sync only the values you want to sync +3. Sync VLANs first to ensure that VLANs are created in NetBox before syncing interfaces, allowing for proper VLAN assignments. Use the VLAN tab on the device sync page to create VLANs from LibreNMS data. ## Cable Management @@ -72,6 +73,16 @@ For best results, align chassis member positions with interface naming patterns. - Remote Device and Remote interface need to be found in NetBox for cable creation to work - Check Device and Interface naming +## VLAN Management + +1. Preparation + - Configure VLAN Groups in NetBox if you want scoped VLAN assignment (e.g., per-site or per-rack groups) + - The plugin resolves VLAN groups using a scope hierarchy: Rack β†’ Location β†’ Site β†’ SiteGroup β†’ Region β†’ Global +2. Review the VLANs tab on the device sync page + - Select the appropriate VLAN Group for each VLAN, or let the plugin auto-select based on scope + - A warning icon appears when a VID does not exist in the selected VLAN group. +3. Sync selected VLANs to create or update them in NetBox + ## Best Practices 1. Regular Maintenance diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index e0d8e72d72..032812ed82 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -30,7 +30,7 @@ Follow these steps to create the `librenms_id` custom field in NetBox: 3. **Configure the Custom Field:** - - **Object Types:** + - **Object Types:** - Check **dcim > device** - Check **virtualization > virtual machine** - Check **dcim > interface** @@ -75,4 +75,4 @@ You can manually assign a value to the `librenms_id` custom field for a device u - If `librenms_id` is set, the plugin will prioritize it over other identification methods. - Ensure the `librenms_id` corresponds to the correct device ID in LibreNMS to prevent mismatches. - The custom field is optional but recommended for optimal plugin performance. -- Using the custom field on interfaces will greatly improve the interface matching required for cable creation. +- Using the custom field on interfaces will greatly improve the interface matching required for cable synchronization. diff --git a/docs/usage_tips/permissions.md b/docs/usage_tips/permissions.md new file mode 100644 index 0000000000..9f9ecfb3f3 --- /dev/null +++ b/docs/usage_tips/permissions.md @@ -0,0 +1,153 @@ +# Permissions & Access Control + +## Overview + +The plugin uses a two-tier permission system that works with NetBox's built-in permissions. Both tiers must be satisfied for users to perform actions: + +1. **Plugin permissions** control access to plugin pages and features +2. **NetBox object permissions** control what objects users can create or modify + +This design ensures the plugin respects your existing NetBox permission structure. A user might have full plugin access but still be restricted to creating certain objects based on their NetBox permissions. + + +> Superusers have full access to all plugin features and NetBox objects by default. So the following applies only to regular users who can be granted specific permissions as needed. + + +## Two-Tier Permission Model + +### How Do the Tiers Work Together? + +A user needs both tiers of permissions to complete an action. For example, to view the Librenms Import page AND import a device: + +1. **Tier 1: Plugin permission**: User needs View AND Change permission on **LibreNMS Settings** + + - View: allows access to the plugin pages and pulling data from LibreNMS. + - Change: allows performing actions that modify Netbox or Librenms data + +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) + +If either permission is missing, the operation fails with an appropriate error message. + +## Creating Permissions + +All permissions are created using Netbox's standard Object permissions UI. + +For details on how NetBox permissions work, see the [NetBox Permissions documentation](https://netboxlabs.com/docs/netbox/administration/permissions/). + +### Plugin Permissions + +To grant a user or group access to the plugin: + +1. Go to **Admin β†’ Permissions** +2. Click **Add** +3. For **Object types**, select "NetBox Librenms Plugin | LibreNMS Settings" +4. Under **Actions**, check the permissions to grant: + - β˜‘ **Can view** β€” for read-only access + - β˜‘ **Can change** β€” for write access (requires View as well) +5. Assign to specific **Users** or **Groups** +6. Click **Save** + +### NetBox Object Permissions + +NetBox object permissions are created similarly but for different object types (DCIM, IPAM, VIRTUALIZATION, etc.). + +### Interface Type Mapping Permissions + +The Interface Type Mapping feature uses its own object permissions in addition to the plugin permissions. To manage interface mappings, users need: + +- **Plugin permission**: View permission on LibreNMS Settings (to access the page) +- **Object permissions**: `netbox_librenms_plugin.add_interfacetypemapping`, `netbox_librenms_plugin.change_interfacetypemapping`, or `netbox_librenms_plugin.delete_interfacetypemapping` as needed + +These permissions are enforced automatically by NetBox's generic views. + + +## Example Scenarios + +### Read-Only Access + +- **Plugin permissions**: Librenms Setting View only (Can view LibreNMS Plugin pages) +- **NetBox permissions**: View permissions for devices, interfaces, etc. + +Users can access all plugin pages, refresh data from LibreNMS, and review comparison tables, but cannot import devices or sync data. + +### Full Plugin Access + +- **Plugin permissions**: View + Change (Can view LibreNMS Plugin Pages and Import and sync devices data) +- **NetBox permissions**: Add/change permissions for devices, interfaces, cables, IP addresses, VLANs + +Users have full access to all plugin features and can import devices, sync interfaces, and create cables. + + +## Further Details + +### Tier 1: Plugin Permissions + +Plugins permissions use the **LibreNMS Settings** model permissions: + +| Permission | NetBox UI Selection | Grants | +|------------|---------------------|--------| +| `view_librenmssettings` | LibreNMS Settings β†’ β˜‘ Can view | Access all plugin pages, view LibreNMS data | +| `change_librenmssettings` | LibreNMS Settings β†’ β˜‘ Can change | Import devices, sync data, save settings | + +Users without View permission won't see the LibreNMS menu or the LibreNMS Sync tab. Users with **View** but not **Change** can browse all plugin pages but cannot perform import or sync actions that modify Netbox data and Librenms data like Locations and Adding devices. + +### Tier 2: NetBox Object Permissions + +When the plugin creates or modifies NetBox objects (devices, interfaces, cables, IP addresses, VLANs), NetBox enforces its standard object permissions. The plugin checks these permissions and will block operations if the user lacks the required access. + +| Plugin Action | Required Object Permissions | +|---------------|----------------------------| +| Import device | `dcim.add_device`, `dcim.add_interface` | +| Import device with VC | Above + `dcim.add_virtualchassis` | +| Import VM | `virtualization.add_virtualmachine` | +| Sync interfaces | `dcim.add_interface`, `dcim.change_interface` | +| Delete interfaces | `dcim.delete_interface` | +| Sync VM interfaces | `virtualization.add_vminterface`, `virtualization.change_vminterface` | +| Delete VM interfaces | `virtualization.delete_vminterface` | +| Sync cables | `dcim.add_cable`, `dcim.change_cable` | +| Sync IP addresses | `ipam.add_ipaddress`, `ipam.change_ipaddress` | +| Sync VLANs | `ipam.add_vlan`, `ipam.change_vlan` | +| Sync device fields | `dcim.change_device` | +| Create platform | `dcim.add_platform` | + + +### Why LibreNMS Settings Permissions? + +NetBox's permission system is object-basedβ€”permissions are tied to specific models like Device, Interface, or Cable. However, the plugin's Import and Sync pages are feature pages that don't have their own dedicated models. They work with LibreNMS data and create or modify existing NetBox objects. + +To control access to these pages, the plugin uses the **LibreNMS Settings** model permissions as a gate for all plugin features: + +- **No dedicated models for pages** β€” The Import and Sync pages aren't objects, so we need an existing model to attach permissions to +- **No custom migrations required** β€” Uses Django's built-in model permissions that NetBox already understands +- **Standard NetBox workflow** β€” Administrators assign permissions the same way they do for any other NetBox object +- **Single permission per access level** β€” One "View" permission for read access, one "Change" permission for write access + +While using a settings model for access control may seem unconventional, it provides a simple and maintainable way to gate plugin access without introducing custom permission infrastructure. + + +## Special note: Background Jobs and Superuser Access + +The device import page can use background jobs to help support large device sets, and virtual chassis detection. However, NetBox restricts access to background job status APIs to superusers. There is no permission in NetBox for this. This is a core design decision, not a plugin limitation. + +| User Type | Background Jobs | +|-----------|-----------------| +| Superuser | Full access to background jobs with real-time status updates | +| Non-superuser | Automatic fallback to synchronous processing | + +The plugin automatically detects whether the current user is a superuser and adjusts behavior accordingly. Non-superuser users don't need to change any settingsβ€”the plugin simply processes requests synchronously instead of as background jobs. All import and filter operations work correctly regardless of superuser status. + +## Troubleshooting + +**User can't see the LibreNMS menu** +: The user doesn't have View permission for the plugin. Add an Object Permission for "LibreNMS Settings" with "Can view" checked. + +**User sees pages but can't import or sync** +: The user has View permission but not Change permission. Edit their Object Permission to also include "Can change". + +**User gets "permission denied" when importing devices** +: The user has plugin permissions but may be missing NetBox object permissions. Check that they have `dcim.add_device` and related permissions. + +**Background jobs show 403 errors in console** +: In normal usage, the UI only enables background jobs for users allowed to use them and falls back to synchronous processing otherwise, so 403s from background job APIs should not appear. If you see these errors, it usually means a direct API call or custom integration is hitting background-task endpoints without superuser access; update that integration to use synchronous flows or run it with appropriate permissions. diff --git a/docs/usage_tips/suggested_workflow.md b/docs/usage_tips/suggested_workflow.md index 7aa0d7696a..d8240d16a5 100644 --- a/docs/usage_tips/suggested_workflow.md +++ b/docs/usage_tips/suggested_workflow.md @@ -49,7 +49,11 @@ Use the [Device Import](../librenms_import/overview.md) feature to bring devices - Start with a small set (single location or device type) to verify your setup - Enable Virtual Chassis detection only when importing stackable switches -## 6. Sync Interfaces +## 6. Sync VLAN +- Create VLAN objects in NetBox from LibreNMS device VLAN data +- Per-VLAN group assignment with scope-aware auto-selection + +## 7. Sync Interfaces After devices are imported, sync their interfaces: @@ -59,7 +63,7 @@ After devices are imported, sync their interfaces: **Why after import**: Interfaces require the device to exist in NetBox first. The `librenms_id` field set during import enables accurate synchronization. -## 7. Sync Cables and IP Addresses +## 8. Sync Cables and IP Addresses Complete your device data by syncing: @@ -68,7 +72,7 @@ Complete your device data by syncing: **Why last**: Both features require that interfaces already exist in NetBox and ideally with the `librenms_id` field set. The `librenms_id` field on interfaces ensures accurate matching. -## 8. Sync Locations (Optional) +## 9. Sync Locations (Optional) If you want to synchronize location latitude/longitude data between NetBox Sites and LibreNMS locations, use the location sync feature. diff --git a/media/configuration.testing.py b/media/configuration.testing.py new file mode 100644 index 0000000000..aa58b59b1e --- /dev/null +++ b/media/configuration.testing.py @@ -0,0 +1,52 @@ +################################################################### +# This file serves as a base configuration for testing purposes # +# only. It is not intended for production use. # +################################################################### + +ALLOWED_HOSTS = ["*"] + +DATABASE = { + "NAME": "netbox", + "USER": "netbox", + "PASSWORD": "netbox", + "HOST": "localhost", + "PORT": "", + "CONN_MAX_AGE": 300, +} + +PLUGINS = [ + "netbox_librenms_plugin", +] + +PLUGINS_CONFIG = { + "netbox_librenms_plugin": { + "servers": { + "default": { + "librenms_url": "https://librenms.example.com", + "api_token": "test-token-for-testing", + } + } + } +} + +REDIS = { + "tasks": { + "HOST": "localhost", + "PORT": 6379, + "PASSWORD": "", + "DATABASE": 0, + "SSL": False, + }, + "caching": { + "HOST": "localhost", + "PORT": 6379, + "PASSWORD": "", + "DATABASE": 1, + "SSL": False, + }, +} + +SECRET_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +API_TOKEN_PEPPERS = { + 1: "TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE", +} diff --git a/mkdocs.yml b/mkdocs.yml index 89c66fc055..cc9b035d8b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,7 @@ nav: - Initial Setup: usage_tips/README.md - Custom Field Setup: usage_tips/custom_field.md - Multi-Server Setup: usage_tips/multi_server_configuration.md + - Permissions & Access: usage_tips/permissions.md - Suggested Workflow: usage_tips/suggested_workflow.md - Import Devices: - Overview: librenms_import/overview.md diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index c06afd6ff9..6bcd0aef20 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -4,6 +4,10 @@ class InterfaceTypeMappingSerializer(NetBoxModelSerializer): + """Serialize InterfaceTypeMapping model for REST API.""" + class Meta: + """Meta options for InterfaceTypeMappingSerializer.""" + model = InterfaceTypeMapping fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"] diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index 3523e2b5d3..768c67f5fe 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -4,11 +4,13 @@ from core.models import Job from django.http import JsonResponse from django.utils import timezone -from django.views.decorators.http import require_http_methods from django_rq import get_queue from netbox.api.viewsets import NetBoxModelViewSet +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import BasePermission, SAFE_METHODS 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 @@ -16,12 +18,31 @@ logger = logging.getLogger(__name__) +class LibreNMSPluginPermission(BasePermission): + """ + Permission class for LibreNMS plugin API endpoints. + + - GET requests require view_librenmssettings + - All other requests require change_librenmssettings + """ + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return request.user.has_perm(PERM_VIEW_PLUGIN) + return request.user.has_perm(PERM_CHANGE_PLUGIN) + + class InterfaceTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for InterfaceTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + queryset = InterfaceTypeMapping.objects.all() serializer_class = InterfaceTypeMappingSerializer -@require_http_methods(["POST"]) +@api_view(["POST"]) +@permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): """ Sync database Job status with RQ job status. diff --git a/netbox_librenms_plugin/constants.py b/netbox_librenms_plugin/constants.py new file mode 100644 index 0000000000..4e542f9d15 --- /dev/null +++ b/netbox_librenms_plugin/constants.py @@ -0,0 +1,6 @@ +# Plugin permissions (from LibreNMSSettings model) +PERM_VIEW_PLUGIN = "netbox_librenms_plugin.view_librenmssettings" +PERM_CHANGE_PLUGIN = "netbox_librenms_plugin.change_librenmssettings" + +# LibreNMS VLAN state values +LIBRENMS_VLAN_STATE_ACTIVE = 1 diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 324b1d62f4..9ec162a64c 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -4,6 +4,10 @@ class InterfaceTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for InterfaceTypeMapping model.""" + class Meta: + """Meta options for InterfaceTypeMappingFilterSet.""" + model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] diff --git a/netbox_librenms_plugin/filtersets.py b/netbox_librenms_plugin/filtersets.py index 2b453514bb..353df21507 100644 --- a/netbox_librenms_plugin/filtersets.py +++ b/netbox_librenms_plugin/filtersets.py @@ -12,6 +12,7 @@ class SiteLocationFilterSet: """ def __init__(self, data, queryset): + """Initialize with form data and queryset.""" self.form_data = data self.queryset = queryset @@ -40,6 +41,8 @@ def _matches_search_criteria(self, item, search_term): @property def form(self): + """Return a bound filter form instance.""" + class FilterForm(forms.Form): """ Form to filter sites and locations by search term. @@ -77,6 +80,8 @@ class DeviceStatusFilterSet(NetBoxModelFilterSet): ) class Meta: + """Meta options for DeviceStatusFilterSet.""" + model = Device fields = ["site", "location", "device_type", "rack", "role"] search_fields = ["device", "site", "device_type", "rack", "role"] @@ -117,12 +122,14 @@ class VMStatusFilterSet(NetBoxModelFilterSet): ) class Meta: + """Meta options for VMStatusFilterSet.""" + model = VirtualMachine fields = ["site", "cluster", "platform"] search_fields = ["virtualmachine", "site", "cluster", "platform"] def search(self, queryset, name, value): - """Search VMs by name, site, cluster, role or platform.""" + """Search VMs by name, site, cluster, or platform.""" if not value.strip(): return queryset return queryset.filter( diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index 3f1bb8aef6..e22cf32222 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -230,10 +230,11 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm): model = InterfaceTypeMapping -class AddToLIbreSNMPV2(forms.Form): +class AddToLIbreSNMPV1V2(forms.Form): """ - Form for adding devices to LibreNMS using SNMPv2 authentication. + Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. Collects hostname/IP and SNMP community string information. + The SNMP version (v1 or v2c) is selected via a toggle button in the template. """ hostname = forms.CharField( @@ -241,7 +242,6 @@ class AddToLIbreSNMPV2(forms.Form): max_length=255, required=True, ) - snmp_version = forms.CharField(widget=forms.HiddenInput(), initial="v2c") community = forms.CharField(label="SNMP Community", max_length=255, required=True) port = forms.IntegerField( label="SNMP Port", diff --git a/netbox_librenms_plugin/import_utils.py b/netbox_librenms_plugin/import_utils.py index 1c22dacdc3..cb9c9a99e7 100644 --- a/netbox_librenms_plugin/import_utils.py +++ b/netbox_librenms_plugin/import_utils.py @@ -6,6 +6,7 @@ - Retrieving filtered LibreNMS devices - Importing single and multiple devices - Smart matching of NetBox objects +- Permission checking for import operations """ import logging @@ -14,6 +15,7 @@ from core.choices import JobStatusChoices from dcim.models import Device, DeviceRole, DeviceType, Rack, Site, VirtualChassis from django.core.cache import cache +from django.core.exceptions import PermissionDenied from django.db import transaction from django.utils import timezone from virtualization.models import Cluster @@ -28,6 +30,52 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Permission Check Helpers +# ============================================================================= + + +def check_user_permissions(user, permissions): + """ + Check if user has all required permissions. + + Args: + user: The user object to check permissions for + permissions: List of permission strings (e.g., ['dcim.add_device', 'dcim.add_interface']) + + Returns: + tuple: (has_all_permissions: bool, missing_permissions: list[str]) + + Raises: + PermissionDenied: If user is None (no user context available) + """ + if user is None: + raise PermissionDenied("No user context available for permission check") + + missing = [perm for perm in permissions if not user.has_perm(perm)] + return (len(missing) == 0, missing) + + +def require_permissions(user, permissions, action_description="perform this action"): + """ + Require user has all permissions, raising PermissionDenied if not. + + Args: + user: The user object to check permissions for + permissions: List of permission strings + action_description: Human-readable description for error message + + Raises: + PermissionDenied: If user lacks any required permission + """ + has_perms, missing = check_user_permissions(user, permissions) + if not has_perms: + missing_str = ", ".join(missing) + raise PermissionDenied( + f"You do not have permission to {action_description}. Missing permissions: {missing_str}" + ) + + def get_cache_metadata_key(server_key: str, filters: dict, vc_enabled: bool) -> str: """ Generate a consistent cache metadata key from filter parameters. @@ -1188,6 +1236,7 @@ def bulk_import_devices_shared( manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, job=None, + user=None, ) -> dict: """ Shared function for importing multiple LibreNMS devices to NetBox. @@ -1204,6 +1253,8 @@ def bulk_import_devices_shared( libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} job: Optional JobRunner instance for progress logging and cancellation checks + user: User performing the import (for permission checks). If job is provided, + user is extracted from job.job.user if not explicitly passed. Returns: dict: Bulk import result with structure: @@ -1215,12 +1266,27 @@ def bulk_import_devices_shared( 'virtual_chassis_created': int # Number of VCs created } + Raises: + PermissionDenied: If user lacks required permissions + Example: >>> # Synchronous usage - >>> result = bulk_import_devices_shared([1, 2, 3, 4, 5]) + >>> result = bulk_import_devices_shared([1, 2, 3, 4, 5], user=request.user) >>> # Background job usage >>> result = bulk_import_devices_shared([1, 2, 3], job=self) """ + # Extract user from job if not explicitly provided + if user is None and job is not None: + user = getattr(job.job, "user", None) + + # Check permissions at start of bulk operation + required_perms = [ + "dcim.add_device", + "dcim.add_interface", + "dcim.add_virtualchassis", + ] + require_permissions(user, required_perms, "import devices") + total = len(device_ids) success_list = [] failed_list = [] @@ -1360,6 +1426,7 @@ def bulk_import_devices( sync_options: dict = None, manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, + user=None, ) -> dict: """ Import multiple LibreNMS devices to NetBox (synchronous). @@ -1375,6 +1442,7 @@ def bulk_import_devices( Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}} libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} + user: User performing the import (for permission checks) Returns: dict: Bulk import result with structure: @@ -1385,6 +1453,9 @@ def bulk_import_devices( 'skipped': List[dict], # Skipped devices (already exist, etc.) 'virtual_chassis_created': int # Number of VCs created } + + Raises: + PermissionDenied: If user lacks required permissions """ return bulk_import_devices_shared( device_ids=device_ids, @@ -1393,6 +1464,7 @@ def bulk_import_devices( manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, job=None, # No job context for synchronous imports + user=user, ) @@ -1532,6 +1604,7 @@ def bulk_import_vms( sync_options: dict = None, libre_devices_cache: dict = None, job=None, + user=None, ) -> dict: """ Import multiple LibreNMS devices as VMs in NetBox. @@ -1549,6 +1622,8 @@ def bulk_import_vms( sync_options: Optional dict with use_sysname, strip_domain settings libre_devices_cache: Optional pre-fetched device data cache job: Optional JobRunner instance for background job logging/cancellation + user: User performing the import (for permission checks). If job is provided, + user is extracted from job.job.user if not explicitly passed. Returns: Dict with keys: @@ -1556,10 +1631,13 @@ def bulk_import_vms( - failed: List of {"device_id": int, "error": str} - skipped: List of {"device_id": int, "reason": str} + Raises: + PermissionDenied: If user lacks required permissions + Example: >>> # Synchronous import from view >>> vm_imports = {123: {"cluster_id": 5, "device_role_id": 2}} - >>> result = bulk_import_vms(vm_imports, api, sync_options) + >>> result = bulk_import_vms(vm_imports, api, sync_options, user=request.user) >>> print(f"Created {len(result['success'])} VMs") >>> >>> # Background job import @@ -1570,6 +1648,13 @@ def bulk_import_vms( apply_role_to_validation, ) + # Extract user from job if not explicitly provided + if user is None and job is not None: + user = getattr(job.job, "user", None) + + # Check permissions at start of bulk operation + require_permissions(user, ["virtualization.add_virtualmachine"], "import VMs") + result = {"success": [], "failed": [], "skipped": []} vm_ids = list(vm_imports.keys()) diff --git a/netbox_librenms_plugin/jobs.py b/netbox_librenms_plugin/jobs.py index a5cdfd615b..bb3d00e37d 100644 --- a/netbox_librenms_plugin/jobs.py +++ b/netbox_librenms_plugin/jobs.py @@ -34,6 +34,8 @@ class FilterDevicesJob(JobRunner): """ class Meta: + """Meta options for FilterDevicesJob.""" + name = "LibreNMS Device Filter" def run( @@ -146,6 +148,8 @@ class ImportDevicesJob(JobRunner): """ class Meta: + """Meta options for ImportDevicesJob.""" + name = "LibreNMS Device Import" def run( @@ -201,6 +205,7 @@ def run( manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, job=self, # Pass job context for logging and cancellation + user=self.job.user, # Pass user for permission checks ) # Import VMs @@ -209,7 +214,9 @@ def run( self.logger.info(f"Importing {len(vm_imports)} VMs...") from netbox_librenms_plugin.import_utils import bulk_import_vms - vm_result = bulk_import_vms(vm_imports, api, sync_options, libre_devices_cache, job=self) + vm_result = bulk_import_vms( + vm_imports, api, sync_options, libre_devices_cache, job=self, user=self.job.user + ) # Combine results imported_device_pks = [item["device"].pk for item in device_result.get("success", []) if item.get("device")] diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index de4192f065..5de9db6c25 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -37,11 +37,31 @@ def __init__(self, server_key=None): # Default to 'default' if still no server_key server_key = server_key or "default" - self.server_key = server_key # Get server configuration servers_config = get_plugin_config("netbox_librenms_plugin", "servers") + # If the requested server_key doesn't exist but there are configured servers, + # only fall back to the first available server when using the auto-default key. + # If a specific (non-default) server_key was requested but not found, raise + # immediately to avoid silently using the wrong LibreNMS instance. + if servers_config and isinstance(servers_config, dict) and server_key not in servers_config: + if server_key != "default": + available = list(servers_config.keys()) + raise KeyError( + f"Server '{server_key}' not found in LibreNMS plugin configuration. Available servers: {available}" + ) + first_key = next(iter(servers_config), None) + if first_key: + logger.info( + "Server '%s' not found in config, falling back to '%s'", + server_key, + first_key, + ) + server_key = first_key + + self.server_key = server_key + if servers_config and isinstance(servers_config, dict) and server_key in servers_config: # Multi-server configuration config = servers_config[server_key] @@ -312,21 +332,32 @@ def get_device_info(self, device_id): except requests.exceptions.RequestException: return False, None - def get_ports(self, device_id): + def get_ports(self, device_id, with_vlans=True): """ Fetch ports data from LibreNMS for a device using its primary IP. + Includes VLAN assignment data (ifVlan, ifTrunk) for interface VLAN sync. + When with_vlans=True, includes detailed VLAN associations (tagged/untagged) + for all ports in a single API call (requires LibreNMS 24.2.0+). + Args: device_id: LibreNMS device ID + with_vlans: Include detailed VLAN data for all ports (default: True) Returns: tuple: (success: bool, data: dict) """ try: + params = { + "columns": "port_id,ifName,ifType,ifSpeed,ifAdminStatus,ifDescr,ifAlias,ifPhysAddress,ifMtu,ifVlan,ifTrunk" + } + if with_vlans: + params["with"] = "vlans" + response = requests.get( f"{self.librenms_url}/api/v0/devices/{device_id}/ports", headers=self.headers, - params={"columns": "port_id,ifName,ifType,ifSpeed,ifAdminStatus,ifDescr,ifAlias,ifPhysAddress,ifMtu"}, + params=params, timeout=DEFAULT_API_TIMEOUT, verify=self.verify_ssl, ) @@ -347,13 +378,13 @@ def add_device(self, data): Args: Dictionary containing device data including: - hostname: Device hostname or IP - - snmp_version: SNMP version (v2c or v3) + - snmp_version: SNMP version (v1, v2c, or v3) - force_add: Skip checks for duplicate device and SNMP reachability (optional, default False) - port: SNMP port (optional, defaults to config value) - transport: SNMP transport protocol (optional: udp, tcp, udp6, tcp6) - port_association_mode: Port identification method (optional: ifIndex, ifName, ifDescr, ifAlias) - poller_group: Poller group ID (optional, defaults to 0) - - community: SNMP community string (for v2c) + - community: SNMP community string (for v1 or v2c) - authlevel, authname, authpass, authalgo, cryptopass, cryptoalgo: SNMP v3 parameters Returns: @@ -375,7 +406,7 @@ def add_device(self, data): if data.get("poller_group") is not None: payload["poller_group"] = data["poller_group"] - if data["snmp_version"] == "v2c": + if data["snmp_version"] in ("v1", "v2c"): payload["community"] = data["community"] elif data["snmp_version"] == "v3": payload.update( @@ -833,3 +864,172 @@ def list_devices(self, filters=None): return False, [] except requests.exceptions.RequestException as e: return False, str(e) + + # ========================================================================= + # VLAN Methods + # ========================================================================= + + def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]: + """ + Fetch all VLANs configured on a device using the resources endpoint. + + This method uses /api/v0/resources/vlans which includes the vlan_id + primary key, unlike /api/v0/devices/{device_id}/vlans which omits it. + + Route: /api/v0/resources/vlans + + Args: + device_id: LibreNMS device ID + + Returns: + tuple: (success: bool, data: list of VLAN dicts or error string) + + Example VLAN: + { + "vlan_id": 123, + "device_id": 1, + "vlan_vlan": 50, + "vlan_domain": 1, + "vlan_name": "ORG_DATA", + "vlan_type": "ethernet", + "vlan_state": 1 + } + """ + try: + response = requests.get( + f"{self.librenms_url}/api/v0/resources/vlans", + headers=self.headers, + timeout=DEFAULT_API_TIMEOUT, + verify=self.verify_ssl, + ) + response.raise_for_status() + + if response.status_code == 200: + result = response.json() + if result.get("status") == "ok": + # Filter VLANs by device_id since resources endpoint returns all VLANs + all_vlans = result.get("vlans", []) + device_vlans = [v for v in all_vlans if str(v.get("device_id")) == str(device_id)] + return True, device_vlans + return False, result.get("message", "Unexpected response format") + + return False, f"HTTP {response.status_code}" + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + return False, "VLANs resource not found" + return False, f"HTTP error: {str(e)}" + except requests.exceptions.RequestException as e: + return False, f"Error connecting to LibreNMS: {str(e)}" + + def get_port_vlan_details(self, port_id: int) -> tuple[bool, dict | str]: + """ + Fetch detailed VLAN associations for a single port. + Required for trunk ports to get the tagged VLANs list. + + Route: /api/v0/ports/{port_id}?with=vlans + + Args: + port_id: LibreNMS port ID + + Returns: + tuple: (success: bool, data: port dict with vlans array or error string) + + Example port: + { + "port_id": 227011, + "ifName": "Te1/1/1", + "ifVlan": "90", + "ifTrunk": "dot1Q", + "vlans": [ + {"vlan": 90, "untagged": 1, "state": "unknown"}, + {"vlan": 50, "untagged": 0, "state": "forwarding"} + ] + } + """ + try: + response = requests.get( + f"{self.librenms_url}/api/v0/ports/{port_id}", + headers=self.headers, + params={"with": "vlans"}, + timeout=DEFAULT_API_TIMEOUT, + verify=self.verify_ssl, + ) + response.raise_for_status() + + if response.status_code == 200: + result = response.json() + port_data = result.get("port", []) + if port_data and len(port_data) > 0: + return True, port_data[0] + return False, "Port not found" + + return False, f"HTTP {response.status_code}" + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + return False, "Port not found in LibreNMS" + return False, f"HTTP error: {str(e)}" + except requests.exceptions.RequestException as e: + return False, f"Error connecting to LibreNMS: {str(e)}" + + def parse_port_vlan_data(self, port_data: dict, interface_name_field: str = "ifName") -> dict: + """ + Transform LibreNMS port VLAN data into normalized structure. + + Args: + port_data: Raw port dict from LibreNMS API + interface_name_field: Field to use for interface name ('ifName' or 'ifDescr') + + Returns: + dict: Normalized structure with: + - port_id: int + - interface_name: str (value from interface_name_field) + - ifName: str (always included for reference) + - ifDescr: str (always included for reference) + - mode: 'access' | 'tagged' | None + - untagged_vlan: int | None + - tagged_vlans: list[int] + """ + port_id = port_data.get("port_id") + if_name = port_data.get("ifName", "") + if_descr = port_data.get("ifDescr", "") + interface_name = port_data.get(interface_name_field, "") or if_name + if_vlan = port_data.get("ifVlan", "") + if_trunk = port_data.get("ifTrunk") + + # Determine 802.1Q mode + if not if_vlan: + mode = None + elif if_trunk == "dot1Q": + mode = "tagged" + else: + mode = "access" + + # Parse VLAN assignments from vlans array if present + vlans_data = port_data.get("vlans", []) + untagged_vlan = None + tagged_vlans = [] + + if vlans_data: + # Parse from detailed vlans array + for vlan_entry in vlans_data: + vlan_id = vlan_entry.get("vlan") + if vlan_entry.get("untagged") == 1: + untagged_vlan = vlan_id + else: + tagged_vlans.append(vlan_id) + elif if_vlan: + # Fallback to ifVlan field for basic port info + try: + untagged_vlan = int(if_vlan) + except (ValueError, TypeError): + pass + + return { + "port_id": port_id, + "interface_name": interface_name, + "ifName": if_name, + "ifDescr": if_descr, + "mode": mode, + "untagged_vlan": untagged_vlan, + "tagged_vlans": sorted(tagged_vlans), + } diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index 64c44a4b59..cd79f47550 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -35,10 +35,13 @@ class LibreNMSSettings(models.Model): ) class Meta: + """Meta options for LibreNMSSettings.""" + verbose_name = "LibreNMS Settings" verbose_name_plural = "LibreNMS Settings" def get_absolute_url(self): + """Return the URL for the settings page.""" return reverse("plugins:netbox_librenms_plugin:settings") def __str__(self): @@ -46,6 +49,8 @@ def __str__(self): class InterfaceTypeMapping(NetBoxModel): + """Map LibreNMS interface types and speeds to NetBox interface types.""" + librenms_type = models.CharField(max_length=100) netbox_type = models.CharField( max_length=50, @@ -59,9 +64,12 @@ class InterfaceTypeMapping(NetBoxModel): ) def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" return reverse("plugins:netbox_librenms_plugin:interfacetypemapping_detail", args=[self.pk]) class Meta: + """Meta options for InterfaceTypeMapping.""" + unique_together = ["librenms_type", "librenms_speed"] def __str__(self): diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index 81703f58b0..a08e62740f 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -1,7 +1,9 @@ from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem +from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN + menu = PluginMenu( - label="LibreNMS Plugin", # This will be your main menu heading + label="LibreNMS", icon_class="mdi mdi-network", groups=( ( @@ -10,12 +12,12 @@ PluginMenuItem( link="plugins:netbox_librenms_plugin:settings", link_text="Plugin Settings", - permissions=["netbox_librenms_plugin.view_librenmssettings"], + permissions=[PERM_VIEW_PLUGIN], ), PluginMenuItem( link="plugins:netbox_librenms_plugin:interfacetypemapping_list", link_text="Interface Mappings", - permissions=["netbox_librenms_plugin.view_interfacetypemapping"], + permissions=[PERM_VIEW_PLUGIN], buttons=( PluginMenuButton( link="plugins:netbox_librenms_plugin:interfacetypemapping_add", @@ -37,7 +39,7 @@ PluginMenuItem( link="plugins:netbox_librenms_plugin:librenms_import", link_text="LibreNMS Import", - permissions=["dcim.view_device"], + permissions=[PERM_VIEW_PLUGIN], ), ), ), @@ -47,17 +49,17 @@ PluginMenuItem( link="plugins:netbox_librenms_plugin:site_location_sync", link_text="Site & Location Sync", - permissions=["dcim.view_site"], + permissions=[PERM_VIEW_PLUGIN], ), PluginMenuItem( link="plugins:netbox_librenms_plugin:device_status_list", link_text="Device Status", - permissions=["dcim.view_device"], + permissions=[PERM_VIEW_PLUGIN], ), PluginMenuItem( link="plugins:netbox_librenms_plugin:vm_status_list", link_text="VM Status", - permissions=["virtualization.view_virtualmachine"], + permissions=[PERM_VIEW_PLUGIN], ), ), ), diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index bc8c84bd1b..c89473c02a 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js @@ -234,6 +234,51 @@ return cookieValue; } + // ============================================ + // USER PREFERENCE PERSISTENCE + // ============================================ + + /** + * Save a user preference via the save-user-pref endpoint. + * Uses the URL from a data attribute on the page to avoid hardcoding. + * + * @param {string} key - Preference key (e.g., 'use_sysname', 'strip_domain') + * @param {*} value - Preference value to save + */ + function savePref(key, value) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value + || getCookie('csrftoken'); + if (!csrfToken) { + return; + } + const savePrefUrl = document.querySelector('[data-save-pref-url]')?.dataset.savePrefUrl; + if (!savePrefUrl) { + return; + } + fetch(savePrefUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ key: key, value: value }) + }).catch(function (err) { + console.debug('savePref: fetch failed:', err.message); + }); + } + + /** + * Initialize toggle listeners for use-sysname and strip-domain preferences. + * Persists toggle state to user preferences on change. + */ + function initializeTogglePrefs() { + const sysname = document.getElementById('use-sysname-toggle'); + const strip = document.getElementById('strip-domain-toggle'); + if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); }); + if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); }); + } + // ============================================ // MODAL MANAGEMENT // ============================================ @@ -252,7 +297,7 @@ const manager = new ModalManager(modalElement); manager.show(); - + // Store backdrop reference for legacy compatibility if (fallbackBackdropRef && manager.backdropElement) { fallbackBackdropRef.element = manager.backdropElement; @@ -667,7 +712,7 @@ }); if (deviceCount) deviceCount.style.display = 'none'; - + if (cancelBtn) { cancelBtn.innerHTML = ' Close'; cancelBtn.onclick = function () { @@ -765,6 +810,10 @@ } }) .then(response => { + // Check for HTTP errors first + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } // Check if response is JSON (background job) or HTML (synchronous) const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { @@ -789,15 +838,11 @@ } else { // Unexpected JSON response alert('Unexpected response from server. Please try again.'); - if (modalInstance) { - modalInstance.hide(); - } + filterModalManager.hide(); } } else if (result.type === 'html') { // Synchronous response - navigate to the URL to reload with results - if (modalInstance) { - modalInstance.hide(); - } + filterModalManager.hide(); // Navigate to the results URL, allowing proper browser history window.location.href = finalUrl; } @@ -813,7 +858,9 @@ // Request was cancelled by user - silent } else { console.error('Error fetching filtered results:', error); - alert('Error loading filtered results. Please try again.'); + // Show more specific error if available + const errorMsg = error.message || 'Error loading filtered results. Please try again.'; + alert(errorMsg); } // Hide modal on error @@ -1297,6 +1344,7 @@ initializeFilterForm(); initializeBulkImport(); initializeHTMXHandlers(); + initializeTogglePrefs(); initializeCachedSearchCountdowns(); initializeCacheExpirationMonitor(); } 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 392e84ead4..cd470af1b1 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 @@ -18,6 +18,22 @@ const TOMSELECT_INIT_DELAY_MS = 100; const COUNTDOWN_UPDATE_INTERVAL_MS = 1000; +// Helper to read CSRF token from cookies +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + /** * Extract device/VM ID and type from current URL pathname. * Supports multiple URL patterns: @@ -134,10 +150,14 @@ function initializeCountdowns() { if (window.ipCountdownInterval) { clearInterval(window.ipCountdownInterval); } + if (window.vlanCountdownInterval) { + clearInterval(window.vlanCountdownInterval); + } window.interfaceCountdownInterval = initializeCountdown("countdown-timer"); window.cableCountdownInterval = initializeCountdown("cable-countdown-timer"); window.ipCountdownInterval = initializeCountdown("ip-countdown-timer"); + window.vlanCountdownInterval = initializeCountdown("vlan-countdown-timer"); } // ============================================ @@ -195,6 +215,8 @@ function initializeCheckboxes() { initializeTableCheckboxes('librenms-cable-table'); initializeTableCheckboxes('librenms-cable-table-vc'); initializeTableCheckboxes('librenms-ipaddress-table'); + initializeTableCheckboxes('librenms-vlan-table'); + initializeTableCheckboxes('librenms-port-vlan-table'); } // ============================================ @@ -211,7 +233,8 @@ function initializeVCMemberSelect() { const cableTable = document.getElementById('librenms-cable-table-vc'); if (interfaceTable) { - const interfaceSelects = interfaceTable.querySelectorAll('.form-select.tomselected'); + // Only target VC member selects, exclude VLAN group selects + const interfaceSelects = interfaceTable.querySelectorAll('.form-select.tomselected:not(.vlan-group-select)'); interfaceSelects.forEach(select => { if (select.tomselect && !select.dataset.interfaceSelectInitialized) { select.dataset.interfaceSelectInitialized = 'true'; @@ -272,6 +295,436 @@ function initializeVRFSelects() { }, TOMSELECT_INIT_DELAY_MS); } +/** + * Initialize VLAN edit buttons that open the VLAN detail modal. + * Each button carries per-VLAN data and VLAN group options as data attributes. + */ +function initializeVlanGroupSelects() { + document.querySelectorAll('.vlan-edit-btn').forEach(btn => { + if (btn.dataset.vlanEditInitialized) return; + btn.dataset.vlanEditInitialized = 'true'; + + btn.addEventListener('click', function (e) { + e.preventDefault(); + openVlanDetailModal(this); + }); + }); +} + +/** + * Open the VLAN detail modal for a specific interface. + * Populates the modal table with per-VLAN rows and group dropdowns. + * + * @param {HTMLElement} btn - The edit button element with data attributes + */ +function openVlanDetailModal(btn) { + const interfaceName = btn.dataset.interface; + const safeName = btn.dataset.safeName; + const deviceId = btn.dataset.deviceId; + const vlans = JSON.parse(btn.dataset.vlans); + const vlanGroups = JSON.parse(btn.dataset.vlanGroups); + + // Set modal title + document.getElementById('vlanModalInterfaceName').textContent = interfaceName; + + // Store current interface context on modal for save handler + const modal = document.getElementById('vlanDetailModal'); + modal.dataset.currentInterface = interfaceName; + modal.dataset.currentSafeName = safeName; + modal.dataset.currentDeviceId = deviceId; + + // Build table rows + const tbody = document.getElementById('vlanDetailTableBody'); + tbody.innerHTML = ''; + + vlans.forEach(vlan => { + const tr = document.createElement('tr'); + + // VID cell + const tdVid = document.createElement('td'); + const vidSpan = document.createElement('span'); + vidSpan.className = vlan.css; + vidSpan.textContent = vlan.vid; + if (vlan.missing) { + vidSpan.innerHTML += ' '; + } + tdVid.appendChild(vidSpan); + tr.appendChild(tdVid); + + // Type cell + const tdType = document.createElement('td'); + tdType.textContent = vlan.type === 'U' ? 'Untagged' : 'Tagged'; + tr.appendChild(tdType); + + // VLAN Group dropdown cell + const tdGroup = document.createElement('td'); + + { + const select = document.createElement('select'); + select.className = 'form-select form-select-sm vlan-modal-group-select'; + select.dataset.vid = vlan.vid; + select.dataset.interface = interfaceName; + select.dataset.safeName = safeName; + + vlanGroups.forEach(group => { + const option = document.createElement('option'); + option.value = group.id; + option.textContent = group.scope ? `${group.name} (${group.scope})` : group.name; + if (String(group.id) === String(vlan.group_id)) { + option.selected = true; + } + select.appendChild(option); + }); + + // On change, update the hidden input for this VLAN immediately + select.addEventListener('change', function () { + updateHiddenVlanGroupInput(safeName, vlan.vid, this.value); + + // Re-verify VLAN colors after group change + verifyVlanInGroup(this, deviceId, vlan.vid, vlan.type, this.value); + }); + + tdGroup.appendChild(select); + } + tr.appendChild(tdGroup); + + tbody.appendChild(tr); + }); + + // Reset "apply to all" checkbox + const applyAllCheckbox = document.getElementById('applyVlanGroupToAll'); + if (applyAllCheckbox) { + applyAllCheckbox.checked = false; + } + + // Show modal via hidden trigger (bootstrap not globally available in NetBox/Tabler) + let trigger = document.getElementById('vlanModalTrigger'); + if (!trigger) { + trigger = document.createElement('button'); + trigger.id = 'vlanModalTrigger'; + trigger.setAttribute('data-bs-toggle', 'modal'); + trigger.setAttribute('data-bs-target', '#vlanDetailModal'); + trigger.style.display = 'none'; + document.body.appendChild(trigger); + } + trigger.click(); +} + +/** + * Update the hidden input for a specific VLAN group assignment. + * + * @param {string} safeName - Safe interface name (slashes replaced) + * @param {number} vid - VLAN ID + * @param {string} groupId - Selected group ID + */ +function updateHiddenVlanGroupInput(safeName, vid, groupId) { + const input = document.querySelector( + `input.vlan-group-hidden[name="vlan_group_${safeName}_${vid}"]` + ); + if (input) { + input.value = groupId; + } +} + +/** + * Verify if a VLAN exists in the selected group and update the modal row status. + * Also updates the css property in the edit button's data-vlans so that when + * the modal is saved, the inline summary can be re-rendered with correct colors. + * + * @param {HTMLSelectElement} select - The group dropdown in the modal + * @param {string} deviceId - Device ID for API call + * @param {number} vid - VLAN ID to verify + * @param {string} vlanType - "U" for untagged, "T" for tagged + * @param {string} groupId - Selected group ID + */ +function verifyVlanInGroup(select, deviceId, vid, vlanType, groupId) { + if (!deviceId) return; + + fetch('/plugins/librenms_plugin/verify-vlan-group/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value + }, + body: JSON.stringify({ + device_id: deviceId, + interface_name: select.dataset.interface, + vlan_group_id: groupId, + vid: String(vid), + vlan_type: vlanType + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + const newCss = data.css_class || 'text-danger'; + const isMissing = data.is_missing; + + // Update the VID color and warning icon in the modal row + const row = select.closest('tr'); + if (row) { + const vidSpan = row.querySelector('td:first-child span'); + if (vidSpan) { + vidSpan.className = newCss; + // Update warning icon + const existingIcon = vidSpan.querySelector('.mdi-alert'); + if (isMissing && !existingIcon) { + vidSpan.innerHTML = vid + ' '; + } else if (!isMissing && existingIcon) { + vidSpan.textContent = String(vid); + } + } + } + + // Store the updated CSS on the modal row for the save handler to read + if (row) { + row.dataset.resolvedCss = newCss; + row.dataset.resolvedMissing = isMissing ? 'true' : 'false'; + } + + // Update the css in the source edit button's data-vlans + const modal = document.getElementById('vlanDetailModal'); + const safeName = modal?.dataset.currentSafeName; + if (safeName) { + const btn = document.querySelector(`.vlan-edit-btn[data-safe-name="${safeName}"]`); + if (btn) { + try { + const btnVlans = JSON.parse(btn.dataset.vlans); + const entry = btnVlans.find(v => String(v.vid) === String(vid)); + if (entry) { + entry.css = newCss; + entry.missing = isMissing; + } + btn.dataset.vlans = JSON.stringify(btnVlans); + } catch (e) { /* skip */ } + } + } + } + }) + .catch(error => { + console.error('VLAN verification error:', error); + }); +} + +/** + * Initialize the VLAN modal save button. + * Handles "Apply to all interfaces" when the checkbox is checked. + */ +function initializeVlanModalSave() { + const saveBtn = document.getElementById('saveVlanGroups'); + if (!saveBtn || saveBtn.dataset.initialized) return; + saveBtn.dataset.initialized = 'true'; + + saveBtn.addEventListener('click', function () { + const applyToAll = document.getElementById('applyVlanGroupToAll')?.checked; + const modalEl = document.getElementById('vlanDetailModal'); + const currentSafeName = modalEl.dataset.currentSafeName; + + // Collect all group selections and resolved CSS from the modal + const modalSelects = document.querySelectorAll('#vlanDetailTableBody .vlan-modal-group-select'); + const vidGroupMap = {}; + const vidCssMap = {}; + const vidMissingMap = {}; + modalSelects.forEach(select => { + vidGroupMap[select.dataset.vid] = select.value; + // Pick up resolved CSS from the verify endpoint (stored on the row) + const row = select.closest('tr'); + if (row && row.dataset.resolvedCss) { + vidCssMap[select.dataset.vid] = row.dataset.resolvedCss; + vidMissingMap[select.dataset.vid] = row.dataset.resolvedMissing === 'true'; + } + }); + + // Determine which buttons to update + const buttonsToUpdate = applyToAll + ? document.querySelectorAll('.vlan-edit-btn') + : document.querySelectorAll(`.vlan-edit-btn[data-safe-name="${currentSafeName}"]`); + + buttonsToUpdate.forEach(btn => { + try { + const btnVlans = JSON.parse(btn.dataset.vlans); + const groups = JSON.parse(btn.dataset.vlanGroups); + const btnSafeName = btn.dataset.safeName; + let changed = false; + + btnVlans.forEach(v => { + if (vidGroupMap.hasOwnProperty(String(v.vid))) { + const newGroupId = vidGroupMap[String(v.vid)]; + v.group_id = newGroupId; + if (v.missing) { + v.group_name = 'Not in NetBox'; + } else { + const matchedGroup = groups.find(g => String(g.id) === String(newGroupId)); + v.group_name = matchedGroup ? matchedGroup.name : '-- No Group (Global) --'; + } + + // Apply resolved CSS from verify endpoint if available + if (vidCssMap.hasOwnProperty(String(v.vid))) { + v.css = vidCssMap[String(v.vid)]; + v.missing = vidMissingMap[String(v.vid)] || false; + } + + changed = true; + + // Update the hidden input for this VID on this interface + const input = document.querySelector( + `input.vlan-group-hidden[name="vlan_group_${btnSafeName}_${v.vid}"]` + ); + if (input) { + input.value = newGroupId; + } + } + }); + + if (changed) { + btn.dataset.vlans = JSON.stringify(btnVlans); + // Update the tooltip and re-render inline summary colors + const summarySpan = btn.previousElementSibling; + if (summarySpan && summarySpan.tagName === 'SPAN') { + const tooltipLines = btnVlans.map(v => + v.missing + ? `VLAN ${v.vid}(${v.type}) \u2192 \u26A0 Not in NetBox` + : `VLAN ${v.vid}(${v.type}) \u2192 ${v.group_name}` + ); + summarySpan.title = tooltipLines.join('\n'); + + // Re-render inline VLAN summary with correct colors + const MAX_INLINE = 3; + const inlineParts = btnVlans.slice(0, MAX_INLINE).map(v => { + const warning = v.missing + ? ' ' + : ''; + return `${v.vid}(${v.type})${warning}`; + }); + let html = inlineParts.join(', '); + if (btnVlans.length > MAX_INLINE) { + const extra = btnVlans.length - MAX_INLINE; + html += ` +${extra} more`; + } + summarySpan.innerHTML = html; + } + } + } catch (e) { + // Skip buttons with invalid data + } + }); + + // Persist overrides in server cache so other table pages pick them up + if (applyToAll && Object.keys(vidGroupMap).length > 0) { + const deviceId = modalEl.dataset.currentDeviceId; + fetch('/plugins/librenms_plugin/save-vlan-group-overrides/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value + }, + body: JSON.stringify({ + device_id: deviceId, + vid_group_map: vidGroupMap + }) + }).then(response => { + if (!response.ok) { + console.error('Failed to persist VLAN group overrides: HTTP', response.status); + } + }).catch(error => { + console.error('Failed to persist VLAN group overrides:', error); + }); + } + + // Close the modal + const closeBtn = modalEl.querySelector('[data-bs-dismiss="modal"]'); + if (closeBtn) { + closeBtn.click(); + } + }); +} + +// ============================================ +// VLAN SYNC TABLE GROUP VERIFICATION +// ============================================ + +/** + * Initialize change listeners on the VLAN sync table's per-row group dropdowns. + * When the user changes the VLAN group for a row, re-checks whether the VID + * exists in the selected group and updates row colors accordingly. + */ +function initializeVlanSyncGroupSelects() { + document.querySelectorAll('.vlan-sync-group-select').forEach(function (select) { + if (select.dataset.vlanSyncInitialized) return; + select.dataset.vlanSyncInitialized = 'true'; + + select.addEventListener('change', function () { + const vid = this.dataset.vlanId; + const vlanName = this.dataset.vlanName; + const groupId = this.value; + + verifyVlanSyncGroup(this, vid, vlanName, groupId); + }); + }); +} + +/** + * Verify if a VLAN exists in the selected group and update the row colors. + * + * @param {HTMLSelectElement} select - The group dropdown element + * @param {string} vid - VLAN ID + * @param {string} vlanName - VLAN name from LibreNMS + * @param {string} groupId - Selected VLAN group ID (empty string = global) + */ +function verifyVlanSyncGroup(select, vid, vlanName, groupId) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); + if (!csrfToken) return; + + fetch('/plugins/librenms_plugin/verify-vlan-sync-group/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken.value + }, + body: JSON.stringify({ + vid: String(vid), + name: vlanName, + vlan_group_id: groupId || null + }) + }) + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + return response.json(); + }) + .then(data => { + if (data.status !== 'success') return; + + const row = select.closest('tr'); + if (!row) return; + + const cssClass = data.css_class || 'text-danger'; + + // Update the VLAN ID cell color + const vidCell = row.querySelector('td[data-col="vlan_id"] span'); + if (vidCell) { + vidCell.className = cssClass; + } + + // Update the Name cell color and tooltip + const nameCell = row.querySelector('td[data-col="name"] span'); + if (nameCell) { + nameCell.className = cssClass; + + // Add/remove name mismatch tooltip + if (data.exists_in_netbox && !data.name_matches && data.netbox_vlan_name) { + nameCell.title = 'NetBox: ' + data.netbox_vlan_name + ' | LibreNMS: ' + vlanName; + } else { + nameCell.title = ''; + } + } + }) + .catch(error => { + console.error('VLAN sync group verification error:', error); + }); +} + /** * Handle VRF selection change and verify IP address assignment. * Sends verification request to backend and updates row status. @@ -640,21 +1093,23 @@ function initializeTabs() { /** * Toggle SNMP form visibility based on selected version. - * Shows either SNMPv2c or SNMPv3 configuration form. + * Shows either SNMPv1/v2c or SNMPv3 configuration form. */ function toggleSNMPForms() { - const snmpSelect = document.querySelector('#add-device-modal select.form-select'); + const snmpSelect = document.getElementById('snmp-version-select'); if (!snmpSelect) return; const version = snmpSelect.value; - const v2Form = document.getElementById('snmpv2-form'); + const v1v2Form = document.getElementById('snmpv1v2-form'); const v3Form = document.getElementById('snmpv3-form'); - if (version === 'v2c') { - v2Form.style.display = 'block'; + if (!v1v2Form || !v3Form) return; + + if (version === 'v1v2c') { + v1v2Form.style.display = 'block'; v3Form.style.display = 'none'; - } else { - v2Form.style.display = 'none'; + } else if (version === 'v3') { + v1v2Form.style.display = 'none'; v3Form.style.display = 'block'; } } @@ -664,7 +1119,7 @@ function toggleSNMPForms() { * Sets up version toggle and displays correct form. */ function initializeSNMPModalScripts() { - const snmpSelect = document.querySelector('#add-device-modal select.form-select'); + const snmpSelect = document.getElementById('snmp-version-select'); if (snmpSelect) { snmpSelect.addEventListener('change', toggleSNMPForms); // Initial call to set the correct form visibility @@ -709,11 +1164,26 @@ function updateInterfaceNameField() { window.history.pushState({}, '', url); // Set HTMX headers for subsequent requests - htmx.config.defaultHeaders['X-Interface-Name-Field'] = this.value; + if (typeof htmx !== 'undefined') { + htmx.config.defaultHeaders['X-Interface-Name-Field'] = this.value; + } + + // Persist to user preferences via API + const savePrefUrl = this.closest('[data-save-pref-url]')?.dataset.savePrefUrl; + if (savePrefUrl) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken'); + if (csrfToken) { + fetch(savePrefUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrfToken}, + body: JSON.stringify({key: 'interface_name_field', value: this.value}) + }).catch(err => console.debug('Failed to save interface_name_field pref:', err)); + } + } // Refresh current tab content const activeTab = document.querySelector('.tab-pane.active'); - if (activeTab) { + if (activeTab && typeof htmx !== 'undefined') { htmx.trigger(activeTab, 'htmx:refresh'); } }); @@ -878,6 +1348,50 @@ function deleteSelectedInterfaces(selectedCheckboxes) { }); } +// ============================================ +// SYNC BUTTON SPINNERS +// ============================================ + +/** + * Initialize spinners on sync form submit buttons. + * Shows the spinner and disables the button when a sync form is submitted. + * Also adds loading indicators to HTMX refresh buttons. + */ +function initializeSyncFormSpinners() { + // Handle regular form submit buttons with sync-spinner inside + document.querySelectorAll('.spinner.spinner-border.d-none').forEach(function (spinner) { + const form = spinner.closest('form'); + const button = spinner.closest('button'); + if (!form || !button || form.dataset.spinnerInitialized) return; + + form.dataset.spinnerInitialized = 'true'; + form.addEventListener('submit', function () { + spinner.classList.remove('d-none'); + spinner.style.width = '1rem'; + spinner.style.height = '1rem'; + button.disabled = true; + }); + }); + + // Handle HTMX refresh buttons (btn-outline-primary with hx-post) + document.querySelectorAll('button[hx-post].btn-outline-primary').forEach(function (button) { + if (button.dataset.spinnerInitialized) return; + button.dataset.spinnerInitialized = 'true'; + + button.addEventListener('htmx:beforeRequest', function () { + const originalText = button.textContent.trim(); + button.dataset.originalText = originalText; + button.disabled = true; + button.innerHTML = '' + originalText; + }); + + button.addEventListener('htmx:afterRequest', function () { + button.disabled = false; + button.innerHTML = button.dataset.originalText || button.textContent; + }); + }); +} + // ============================================ // INITIALIZATION // ============================================ @@ -890,6 +1404,8 @@ function initializeScripts() { initializeCheckboxes(); initializeVCMemberSelect(); initializeVRFSelects(); + initializeVlanGroupSelects(); + initializeVlanModalSave(); initializeFilters(); initializeCountdowns(); initializeCheckboxListeners(); @@ -898,6 +1414,8 @@ function initializeScripts() { setInterfaceNameFieldFromURL(); initializeTabs(); initializeNetBoxOnlyInterfaces(); + initializeSyncFormSpinners(); + initializeVlanSyncGroupSelects(); } @@ -905,6 +1423,13 @@ function initializeScripts() { document.addEventListener('DOMContentLoaded', function () { initializeScripts(); + // Configure HTMX to include CSRF token in all requests + document.body.addEventListener('htmx:configRequest', function (event) { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); + if (csrfToken) { + event.detail.headers['X-CSRFToken'] = csrfToken.value; + } + }); }); // Initialize scripts after HTMX swaps content diff --git a/netbox_librenms_plugin/tables/VM_status.py b/netbox_librenms_plugin/tables/VM_status.py index dd86d0e428..5227ca9bbd 100644 --- a/netbox_librenms_plugin/tables/VM_status.py +++ b/netbox_librenms_plugin/tables/VM_status.py @@ -34,6 +34,8 @@ def render_librenms_status(self, value, record): return mark_safe(f'{status}') class Meta(VirtualMachineTable.Meta): + """Meta options for VMStatusTable.""" + model = VirtualMachine fields = ( "pk", diff --git a/netbox_librenms_plugin/tables/__init__.py b/netbox_librenms_plugin/tables/__init__.py index e69de29bb2..32bade63f0 100644 --- a/netbox_librenms_plugin/tables/__init__.py +++ b/netbox_librenms_plugin/tables/__init__.py @@ -0,0 +1,21 @@ +from .cables import LibreNMSCableTable +from .device_status import DeviceStatusTable +from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable +from .ipaddresses import IPAddressTable +from .locations import SiteLocationSyncTable +from .mappings import InterfaceTypeMappingTable +from .vlans import LibreNMSVLANTable +from .VM_status import VMStatusTable + +__all__ = [ + "DeviceStatusTable", + "InterfaceTypeMappingTable", + "IPAddressTable", + "LibreNMSCableTable", + "LibreNMSInterfaceTable", + "LibreNMSVLANTable", + "LibreNMSVMInterfaceTable", + "SiteLocationSyncTable", + "VCInterfaceTable", + "VMStatusTable", +] diff --git a/netbox_librenms_plugin/tables/cables.py b/netbox_librenms_plugin/tables/cables.py index e08918b307..6ef42c784f 100644 --- a/netbox_librenms_plugin/tables/cables.py +++ b/netbox_librenms_plugin/tables/cables.py @@ -46,6 +46,7 @@ class LibreNMSCableTable(tables.Table): ) def __init__(self, *args, device=None, **kwargs): + """Initialize table with optional device context.""" self.device = device super().__init__(*args, **kwargs) self.tab = "cables" @@ -53,26 +54,31 @@ def __init__(self, *args, device=None, **kwargs): self.prefix = "cables_" def render_remote_device(self, value, record): + """Render remote device name as a link if URL is available.""" if url := record.get("remote_device_url"): return format_html('{}', url, value) return value def render_local_port(self, value, record): + """Render local port name as a link if URL is available.""" if url := record.get("local_port_url"): return format_html('{}', url, value) return value def render_remote_port(self, value, record): + """Render remote port name as a link if URL is available.""" if url := record.get("remote_port_url"): return format_html('{}', url, value) return value def render_cable_status(self, value, record): + """Render cable status as a link if cable URL is available.""" if url := record.get("cable_url"): return format_html('{}', url, value) return value def configure(self, request): + """Configure pagination for the table using the current request.""" paginate = { "paginator_class": EnhancedPaginator, "per_page": get_table_paginate_count(request, self.prefix), @@ -80,6 +86,8 @@ def configure(self, request): tables.RequestConfig(request, paginate).configure(self) class Meta: + """Define column sequence, row attributes, and table styling.""" + sequence = [ "selection", "local_port", @@ -108,9 +116,11 @@ class VCCableTable(LibreNMSCableTable): ) def __init__(self, *args, device=None, **kwargs): + """Initialize the VC cable table with device context.""" super().__init__(*args, device=device, **kwargs) def render_device_selection(self, value, record): + """Render a dropdown to select the virtual chassis member for a port.""" members = self.device.virtual_chassis.members.all() chassis_member = get_virtual_chassis_member(self.device, record["local_port"]) selected_member_id = chassis_member.id if chassis_member else self.device.id @@ -127,6 +137,8 @@ def render_device_selection(self, value, record): ) class Meta(LibreNMSCableTable.Meta): + """Define column sequence and attributes for the VC cable table.""" + sequence = [ "selection", "device_selection", diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py index c5dedaf1c8..8bf978ccd4 100644 --- a/netbox_librenms_plugin/tables/device_status.py +++ b/netbox_librenms_plugin/tables/device_status.py @@ -25,6 +25,7 @@ class DeviceStatusTable(DeviceTable): ) def render_librenms_status(self, value, record): + """Render LibreNMS sync status with link to sync page.""" sync_url = reverse( "plugins:netbox_librenms_plugin:device_librenms_sync", kwargs={"pk": record.pk}, @@ -52,6 +53,8 @@ def render_librenms_status(self, value, record): return mark_safe(f'{status}') class Meta(DeviceTable.Meta): + """Meta options for DeviceStatusTable.""" + model = Device fields = ( "pk", @@ -89,7 +92,9 @@ class DeviceImportTable(tables.Table): name = "DeviceImportTable" # Required by NetBox table utilities def __init__(self, *args, **kwargs): + """Initialize table with cached querysets and apply sorting.""" super().__init__(*args, **kwargs) + # Cache querysets to avoid N queries per render from dcim.models import DeviceRole from virtualization.models import Cluster @@ -127,6 +132,7 @@ def _sort_data(self): # Sort the data list in place # Handle None values by treating them as empty strings for sorting def sort_key(item): + """Return lowercase sort value for a data field.""" value = item.get(data_key, "") return (value or "").lower() if isinstance(value, str) else str(value or "") @@ -425,6 +431,7 @@ def render_actions(self, value, record): """ Render action buttons for import using HTMX. Shows Import button if can import, otherwise shows Preview/Configure. + Permission checks are handled by backend require_write_permission() which shows toast. """ validation = record.get("_validation", {}) device_id = record.get("device_id") @@ -625,6 +632,8 @@ def _build_vc_attributes(validation: dict, record: dict) -> str: ) class Meta: + """Meta options for DeviceImportTable.""" + # No model - we're working with LibreNMS API dictionaries, not Django model instances # This prevents NetBoxTable from auto-adding custom fields from Device model diff --git a/netbox_librenms_plugin/tables/interfaces.py b/netbox_librenms_plugin/tables/interfaces.py index 2e56edc7ad..5ceb30bf51 100644 --- a/netbox_librenms_plugin/tables/interfaces.py +++ b/netbox_librenms_plugin/tables/interfaces.py @@ -1,5 +1,7 @@ +import json as json_module + import django_tables2 as tables -from django.utils.html import format_html +from django.utils.html import escape, format_html from django.utils.safestring import mark_safe from netbox.tables.columns import BooleanColumn, ToggleColumn from utilities.paginator import EnhancedPaginator @@ -7,10 +9,14 @@ from netbox_librenms_plugin.models import InterfaceTypeMapping from netbox_librenms_plugin.utils import ( + check_vlan_group_matches, convert_speed_to_kbps, format_mac_address, get_interface_name_field, + get_missing_vlan_warning, get_table_paginate_count, + get_tagged_vlan_css_class, + get_untagged_vlan_css_class, get_virtual_chassis_member, ) @@ -21,11 +27,14 @@ class LibreNMSInterfaceTable(tables.Table): """ class Meta: + """Meta options for LibreNMSInterfaceTable.""" + sequence = [ "selection", "name", "type", "speed", + "vlans", "mac_address", "mtu", "enabled", @@ -37,9 +46,11 @@ class Meta: "id": "librenms-interface-table", } - def __init__(self, *args, device=None, interface_name_field=None, **kwargs): + def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs): + """Initialize table with device context and interface name field.""" self.device = device self.interface_name_field = interface_name_field or get_interface_name_field() + self.vlan_groups = vlan_groups or [] # Update column accessors after initialization for column in ["selection", "name"]: @@ -49,9 +60,9 @@ def __init__(self, *args, device=None, interface_name_field=None, **kwargs): self._meta.row_attrs = { "data-interface": lambda record: record.get(self.interface_name_field), "data-name": lambda record: record.get(self.interface_name_field), - "data-enabled": lambda record: record.get("ifAdminStatus", "").lower() - if record.get("ifAdminStatus") - else "", + "data-enabled": lambda record: ( + str(record.get("ifAdminStatus")).lower() if record.get("ifAdminStatus") is not None else "" + ), } super().__init__(*args, **kwargs) @@ -88,6 +99,197 @@ def __init__(self, *args, device=None, interface_name_field=None, **kwargs): verbose_name="LibreNMS ID", attrs={"td": {"data-col": "librenms_id"}}, ) + vlans = tables.Column( + verbose_name="VLANs", + empty_values=(), + orderable=False, + attrs={"td": {"data-col": "vlans"}}, + ) + + def render_vlans(self, value, record): + """ + Render VLANs column showing untagged and tagged VLANs. + Format: "100(U), 200(T), 300(T)" or "100(U)" for access ports. + + Color logic: + - Red + warning icon: VLAN not in any NetBox group (cannot sync) + - Red: Not present in NetBox (no VLAN assigned on interface) + - Orange: Mismatched (different untagged VLAN assigned) + - Green: Matching (VLAN matches NetBox assignment) + + Compact display: shows up to 3 VLANs inline, then summarizes. + An edit button opens the VLAN detail modal. + Hidden inputs store per-VLAN group assignments for form submission. + """ + untagged = record.get("untagged_vlan") + tagged = record.get("tagged_vlans", []) + missing_vlans = record.get("missing_vlans", []) + + # Get NetBox interface for comparison + exists_in_netbox = record.get("exists_in_netbox", False) + netbox_interface = record.get("netbox_interface") + + # Get NetBox VLAN assignments (VID + group for group-aware comparison) + netbox_untagged_vid = None + netbox_untagged_group_id = None + netbox_tagged_vids = set() + netbox_tagged_group_ids = {} + if netbox_interface: + if netbox_interface.untagged_vlan: + netbox_untagged_vid = netbox_interface.untagged_vlan.vid + netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id + for v in netbox_interface.tagged_vlans.all(): + netbox_tagged_vids.add(v.vid) + netbox_tagged_group_ids[v.vid] = v.group_id + + all_vlans = [] + if untagged: + all_vlans.append(("U", untagged)) + for vid in sorted(tagged): + all_vlans.append(("T", vid)) + + if not all_vlans: + return format_html("β€”") + + interface_name = record.get(self.interface_name_field, "") + safe_name = interface_name.replace("/", "_").replace(":", "_") + + # Build compact colored summary (show up to 3 VLANs, summarize rest) + vlan_group_map = record.get("vlan_group_map", {}) + MAX_INLINE = 3 + inline_parts = [] + for vlan_type, vid in all_vlans[:MAX_INLINE]: + selected_gid = self._parse_group_id(vlan_group_map.get(vid, {}).get("group_id", "")) + group_matches = check_vlan_group_matches( + vlan_type, + vid, + selected_gid, + netbox_untagged_group_id, + netbox_tagged_group_ids, + netbox_untagged_vid, + netbox_tagged_vids, + ) + if vlan_type == "U": + css = get_untagged_vlan_css_class( + vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches + ) + else: + css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches) + warning = get_missing_vlan_warning(vid, missing_vlans) + inline_parts.append(f'{vid}({vlan_type}){warning}') + + summary = ", ".join(inline_parts) + if len(all_vlans) > MAX_INLINE: + extra = len(all_vlans) - MAX_INLINE + summary += f' +{extra} more' + + # Build tooltip showing auto-selected VLAN group per VLAN + tooltip_lines = [] + for vlan_type, vid in all_vlans: + if vid in missing_vlans: + tooltip_lines.append(f"VLAN {vid}({vlan_type}) β†’ ⚠ Not in NetBox") + else: + group_info = vlan_group_map.get(vid, {}) + group_name = group_info.get("group_name", "Global") + tooltip_lines.append(f"VLAN {vid}({vlan_type}) β†’ {escape(group_name)}") + tooltip_text = " ".join(tooltip_lines) + + # Build hidden inputs for per-VLAN group selections (submitted with form) + hidden_inputs = [] + for vlan_type, vid in all_vlans: + group_info = vlan_group_map.get(vid, {}) + group_id = group_info.get("group_id", "") + hidden_inputs.append( + format_html( + '', + safe_name, + vid, + group_id, + interface_name, + vid, + ) + ) + + # Build JSON data for modal (use proper json serialization for safety) + vlan_json_items = [] + for vlan_type, vid in all_vlans: + group_info = vlan_group_map.get(vid, {}) + is_missing = vid in missing_vlans + selected_gid = self._parse_group_id(group_info.get("group_id", "")) + group_matches = check_vlan_group_matches( + vlan_type, + vid, + selected_gid, + netbox_untagged_group_id, + netbox_tagged_group_ids, + netbox_untagged_vid, + netbox_tagged_vids, + ) + if vlan_type == "U": + css = get_untagged_vlan_css_class( + vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches + ) + else: + css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches) + display_group_name = "Not in NetBox" if is_missing else group_info.get("group_name", "Global") + vlan_json_items.append( + { + "vid": vid, + "type": vlan_type, + "group_id": group_info.get("group_id", ""), + "group_name": display_group_name, + "css": css, + "missing": is_missing, + } + ) + vlan_json = json_module.dumps(vlan_json_items) + + device_id = self.device.pk if self.device else "" + + # Build vlan_groups JSON for modal dropdowns + group_options = [{"id": "", "name": "-- No Group (Global) --", "scope": ""}] + for group in self.vlan_groups: + scope_info = str(group.scope) if hasattr(group, "scope") and group.scope else "" + group_options.append({"id": str(group.pk), "name": group.name, "scope": scope_info}) + + groups_json = json_module.dumps(group_options) + + # Escape JSON for safe embedding in HTML attributes + escaped_vlan_json = escape(vlan_json) + escaped_groups_json = escape(groups_json) + + edit_btn = format_html( + '', + interface_name, + safe_name, + device_id, + escaped_vlan_json, + escaped_groups_json, + ) + + hidden_inputs_html = mark_safe("".join(str(h) for h in hidden_inputs)) + + return format_html( + '{}{}{}', + mark_safe(tooltip_text), + mark_safe(summary), + edit_btn, + hidden_inputs_html, + ) + + @staticmethod + def _parse_group_id(group_id_str): + """Normalize a group ID string to int or None for comparison.""" + return int(group_id_str) if group_id_str else None def render_speed(self, value, record): """Render interface speed with appropriate styling based on comparison with NetBox""" @@ -316,8 +518,11 @@ class VCInterfaceTable(LibreNMSInterfaceTable): attrs={"td": {"data-col": "device_selection"}}, ) - def __init__(self, *args, device=None, interface_name_field=None, **kwargs): - super().__init__(*args, device=device, interface_name_field=interface_name_field, **kwargs) + def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs): + """Initialize VC interface table with device and name field.""" + super().__init__( + *args, device=device, interface_name_field=interface_name_field, vlan_groups=vlan_groups, **kwargs + ) # Ensure device_selection column is visible if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis: self.columns.show("device_selection") @@ -356,17 +561,21 @@ def render_device_selection(self, value, record): ) def format_interface_data(self, port_data, device): + """Format interface data including VC device selection column.""" formatted_data = super().format_interface_data(port_data, device) formatted_data["device_selection"] = self.render_device_selection(None, port_data) return formatted_data class Meta: + """Meta options for VCInterfaceTable.""" + sequence = [ "selection", "device_selection", "name", "type", "speed", + "vlans", "mac_address", "mtu", "enabled", @@ -384,9 +593,12 @@ class LibreNMSVMInterfaceTable(LibreNMSInterfaceTable): """ class Meta(LibreNMSInterfaceTable.Meta): + """Meta options for LibreNMSVMInterfaceTable.""" + sequence = [ "selection", "name", + "vlans", "mac_address", "mtu", "enabled", diff --git a/netbox_librenms_plugin/tables/ipaddresses.py b/netbox_librenms_plugin/tables/ipaddresses.py index 65ef58b4b8..c076d54ba8 100644 --- a/netbox_librenms_plugin/tables/ipaddresses.py +++ b/netbox_librenms_plugin/tables/ipaddresses.py @@ -12,9 +12,12 @@ class IPAddressTable(tables.Table): """ def __init__(self, *args, **kwargs): + """Initialize IP address table.""" super().__init__(*args, **kwargs) class Meta: + """Meta options for IPAddressTable.""" + sequence = [ "selection", "address", diff --git a/netbox_librenms_plugin/tables/locations.py b/netbox_librenms_plugin/tables/locations.py index 8cd017d9de..b37a8731f0 100644 --- a/netbox_librenms_plugin/tables/locations.py +++ b/netbox_librenms_plugin/tables/locations.py @@ -11,17 +11,19 @@ class SiteLocationSyncTable(tables.Table): """ netbox_site = tables.Column(linkify=True) - latitude = tables.Column(accessor="netbox_site.latitude") - longitude = tables.Column(accessor="netbox_site.longitude") - librenms_location = tables.Column(accessor="librenms_location.location", verbose_name="LibreNMS Location") - librenms_latitude = tables.Column(accessor="librenms_location.lat", verbose_name="LibreNMS Latitude") - librenms_longitude = tables.Column(accessor="librenms_location.lng", verbose_name="LibreNMS Longitude") + latitude = tables.Column(accessor="netbox_site__latitude") + longitude = tables.Column(accessor="netbox_site__longitude") + librenms_location = tables.Column(accessor="librenms_location__location", verbose_name="LibreNMS Location") + librenms_latitude = tables.Column(accessor="librenms_location__lat", verbose_name="LibreNMS Latitude") + librenms_longitude = tables.Column(accessor="librenms_location__lng", verbose_name="LibreNMS Longitude") actions = tables.Column(empty_values=()) def render_latitude(self, value, record): + """Render latitude with sync-status styling.""" return self.render_coordinate(value, record.is_synced) def render_longitude(self, value, record): + """Render longitude with sync-status styling.""" return self.render_coordinate(value, record.is_synced) def render_coordinate(self, value, is_synced): @@ -68,6 +70,8 @@ def configure(self, request): tables.RequestConfig(request, paginate).configure(self) class Meta: + """Meta options for SiteLocationSyncTable.""" + fields = ( "netbox_site", "latitude", diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py index beb4e77ca4..73949fd2c8 100644 --- a/netbox_librenms_plugin/tables/mappings.py +++ b/netbox_librenms_plugin/tables/mappings.py @@ -16,6 +16,8 @@ class InterfaceTypeMappingTable(NetBoxTable): actions = columns.ActionsColumn(actions=("edit", "delete")) class Meta: + """Meta options for InterfaceTypeMappingTable.""" + model = InterfaceTypeMapping fields = ( "id", diff --git a/netbox_librenms_plugin/tables/vlans.py b/netbox_librenms_plugin/tables/vlans.py new file mode 100644 index 0000000000..75c2f5e771 --- /dev/null +++ b/netbox_librenms_plugin/tables/vlans.py @@ -0,0 +1,165 @@ +import django_tables2 as tables +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from netbox.tables.columns import ToggleColumn +from utilities.paginator import EnhancedPaginator + +from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE +from netbox_librenms_plugin.utils import get_table_paginate_count, get_vlan_sync_css_class + + +class LibreNMSVLANTable(tables.Table): + """ + Table for displaying LibreNMS VLAN data for a device. + Shows VLANs configured on the device and their sync status with NetBox. + Includes per-row VLAN group selection dropdown. + """ + + class Meta: + sequence = [ + "selection", + "vlan_id", + "name", + "vlan_group_selection", + "type", + "state", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-vlan-table", + } + row_attrs = { + "data-vlan-id": lambda record: record.get("vlan_id"), + } + + def __init__(self, *args, vlan_groups=None, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = "vlans_" + self.vlan_groups = vlan_groups or [] + + selection = ToggleColumn( + orderable=False, + visible=True, + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + accessor="vlan_id", + ) + + vlan_id = tables.Column( + accessor="vlan_id", + verbose_name="VLAN ID", + attrs={"td": {"data-col": "vlan_id"}}, + ) + + name = tables.Column( + accessor="name", + verbose_name="Name", + attrs={"td": {"data-col": "name"}}, + ) + + vlan_group_selection = tables.Column( + verbose_name="VLAN Group", + empty_values=(), + orderable=False, + attrs={"td": {"data-col": "vlan_group_selection"}}, + ) + + type = tables.Column( + accessor="type", + verbose_name="Type", + attrs={"td": {"data-col": "type"}}, + ) + + state = tables.Column( + accessor="state", + verbose_name="State", + attrs={"td": {"data-col": "state"}}, + ) + + def render_vlan_id(self, value, record): + """Render VLAN ID with color based on sync status.""" + css_class = get_vlan_sync_css_class( + record.get("exists_in_netbox", False), + record.get("name_matches", True), + ) + return format_html('{}', css_class, value) + + def render_name(self, value, record): + """Render VLAN name with color based on sync status.""" + css_class = get_vlan_sync_css_class( + record.get("exists_in_netbox", False), + record.get("name_matches", True), + ) + + # Add tooltip on name mismatch + if record.get("exists_in_netbox") and not record.get("name_matches", True): + netbox_name = record.get("netbox_vlan_name", "") + tooltip = f"NetBox: {netbox_name} | LibreNMS: {value}" + return format_html( + '{}', + css_class, + tooltip, + value or "", + ) + + return format_html('{}', css_class, value or "") + + def render_vlan_group_selection(self, value, record): + """ + Render per-row VLAN group dropdown. + + Auto-selects based on matching priority: + 1. Existing NetBox VLAN's group (if exists_in_netbox) + 2. Unique VID match (if VID exists in exactly one group) + 3. No selection (with warning icon if ambiguous) + """ + vlan_id = record.get("vlan_id") + + # Determine which group to auto-select + selected_group_id = None + + # Priority 1: Existing NetBox VLAN group + if record.get("exists_in_netbox") and record.get("netbox_vlan_group_id"): + selected_group_id = record["netbox_vlan_group_id"] + elif record.get("auto_selected_group_id"): + # Priority 2: unique VID match + selected_group_id = record["auto_selected_group_id"] + + # Build the select element + options = [''] + for group in self.vlan_groups: + selected = "selected" if group.pk == selected_group_id else "" + scope_info = f" ({group.scope})" if group.scope else "" + options.append(f'') + + select_html = format_html( + '', + vlan_id, + vlan_id, + record.get("name", ""), + mark_safe("".join(options)), + ) + + # Add warning icon if ambiguous (VID exists in multiple groups at same priority level) + if record.get("is_ambiguous") and not record.get("exists_in_netbox"): + warning_html = format_html( + '' + ) + return format_html("{}{}", select_html, warning_html) + + return select_html + + def render_state(self, value, record): + """Render VLAN state (active/inactive).""" + if value == LIBRENMS_VLAN_STATE_ACTIVE or value == "active": + return format_html('Active') + return format_html('Inactive') + + def configure(self, request): + """Configure the table with pagination.""" + paginate = { + "paginator_class": EnhancedPaginator, + "per_page": get_table_paginate_count(request, self.prefix), + } + tables.RequestConfig(request, paginate).configure(self) diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html index f12904ff40..c42534a73d 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html @@ -70,6 +70,13 @@ +{% else %} +
+
+ +

No cable data loaded. Click Refresh Cables to fetch data from LibreNMS.

+
+
{% endif %} - \ No newline at end of file + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html index d0241d53fb..fa1d3c0d73 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html @@ -34,4 +34,4 @@

Interface Sync

{% include 'netbox_librenms_plugin/_interface_sync_content.html' %} -
\ No newline at end of file + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html index c2b62d2d6b..f3d55b8710 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html @@ -33,6 +33,11 @@
Exclude from Sync:
+
+ VLANs + +
MAC Exclude from Sync:
+ + + +{% else %} +
+
+ +

No interface data loaded. Click Refresh Interfaces to fetch data from LibreNMS.

+
+
{% endif %} @@ -183,6 +232,18 @@
Virtual Chassis Member Selection
virutal chassis. The selected device can be changed in the table before sync the interface.

When changing the selected device for an interface row, the data will be checked again against the newly selected device.

+
VLAN Group Selection
+

Each VLAN in the VLANs column is automatically assigned to a VLAN group using a priority + scope order: Rack β†’ Location β†’ Site β†’ Site Group β†’ Region β†’ Global. + The most specific scope that contains the VLAN wins. If a VLAN exists in only one group, + that group is selected regardless of scope. Click the icon + on any row to change the group assignment for individual VLANs.

+

Check Apply group assignments to all interfaces with matching VLANs in the + modal to apply your selection to every interface that shares the same VLAN IDs. This choice is + saved for the duration of the cache, so subsequent table pages will also use it.

+

VLANs shown with a + warning icon do not exist in the selected VLAN group in NetBox yet. Use the VLAN Sync tab to create them + before syncing interfaces.

- \ No newline at end of file + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html index f5cbdf5aef..22053694e9 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html @@ -34,4 +34,4 @@

IP Address Sync

{% include 'netbox_librenms_plugin/_ipaddress_sync_content.html' %} -
\ No newline at end of file + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html index 755d56e4b7..a31114de1f 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html @@ -48,7 +48,7 @@ flex-wrap: wrap; align-items: center; } - + /* VRF dropdown styles */ td[data-col="vrf"] { width: 250px; @@ -58,7 +58,7 @@ width: 100%; max-width: 100%; } - + /* Ensure select elements in VRF column maintain consistent width */ td[data-col="vrf"] select.form-select { width: 100%; @@ -73,4 +73,11 @@ -{% endif %} \ No newline at end of file +{% else %} +
+
+ +

No IP address data loaded. Click Refresh IP Addresses to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html new file mode 100644 index 0000000000..ecf4177fed --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html @@ -0,0 +1,30 @@ +{% load helpers %} +{% load static %} + + +
+

VLAN 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/_vlan_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html new file mode 100644 index 0000000000..017f888ec1 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html @@ -0,0 +1,62 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + +{% if vlan_sync.error_message %} + +
+ {{ vlan_sync.error_message }} +
+{% elif not vlan_sync.vlan_table or not vlan_sync.vlan_table.rows %} + +
+
+ +

No VLAN data loaded. Click Refresh VLANs to fetch data from LibreNMS.

+
+
+{% else %} + +{% with model_name=vlan_sync.object|meta:"model_name" %} +
+{% endwith %} + {% csrf_token %} + + +
+
+ + + Select a VLAN Group for each row, or leave empty for global VLANs + +
+
+ {% if vlan_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ Matching values + Mismatched values + Not present in NetBox +
+
+
+ +
+
+
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %} + {% include 'inc/table.html' with table=vlan_sync.vlan_table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %} +
+
+
+
+ +{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html index 611efd5042..76aac95546 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html @@ -19,4 +19,4 @@ {# Consume and clear any pending Django messages to prevent reappearing toasts #}
{% for _ in messages %}{% endfor %} -
\ No newline at end of file + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html index af754a0b36..119e4e1180 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html @@ -27,4 +27,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html index 0fbedc2182..3044c4dda5 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html @@ -3,10 +3,10 @@ {% block content %}

Interface Type Mapping

-

This section allows you to map LibreNMS interface types to NetBox interface types. - When synchronizing interfaces from LibreNMS, these mappings will be used to ensure +

This section allows you to map LibreNMS interface types to NetBox interface types. + When synchronizing interfaces from LibreNMS, these mappings will be used to ensure correct interface type assignment in NetBox.

Example: Map LibreNMS type "ethernetCsmacd" to NetBox type "1000base-t"

- {{ block.super }} -{% endblock %} \ No newline at end of file + {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html index e2009dea59..0013ec5be3 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html @@ -200,6 +200,7 @@
Search Instructions
+ {% if can_use_background_jobs %} {{ filter_form.use_background_job }}
@@ -334,10 +344,11 @@
{% endif %} {% endif %} {# Import Settings #} -
+
Settings:
-
-
{% endblock %} - 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 8d9a553e9a..cfcc6527a1 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 @@ -59,7 +59,14 @@
-
LibreNMS Status
+
+ LibreNMS Status + {% if mismatched_device %} + + Mismatch found + + {% endif %} +
@@ -84,6 +91,52 @@
LibreNMS Status
+ + + + + + + + + + + + + + {% if librenms_device_serial and librenms_device_serial != "-" %} + + + + + {% endif %} + {% with model_name=object|meta:"model_name" %} {% if model_name == "device" %} @@ -420,6 +473,7 @@
Device Information Sync
{% endif %} {% elif librenms_device_id %} {% if found_in_librenms %} +
+ title="Select preferred interface name field" + data-save-pref-url="{% url 'plugins:netbox_librenms_plugin:save_user_pref' %}"> @@ -470,40 +535,18 @@
Device Information Sync
data-tab-id="ipaddresses"> {% include 'netbox_librenms_plugin/_ipaddress_sync.html' %}
-
- - -{% elif mismatched_device %} -
-
- -
- Device Mismatch: - The LibreNMS device with ID {{ librenms_device_id }} does not match the NetBox device. -
- - Netbox Device Details: -
    -
  • NetBox Name: {{ object.name }}
  • -
  • Primary IP: {{ object.primary_ip.address.ip|default:'-' }}
  • -
  • Primary IP DNS Name: {{ object.primary_ip.dns_name|default:'-' }}
  • -
- LibreNMS Device with ID {{ librenms_device_id }}: -
    -
  • LibreNMS Name: {{ sysName|default:'-' }}
  • -
  • Hardware: {{ librenms_device_hardware }}
  • -
  • IP Address: {{ librenms_device_ip }}
  • -
- Options: -
    -
  • Remove custom field value and let LibreNMS plugin try to find it
  • -
  • Manually enter the correct LibreNMS device ID
  • -
-
-
+ {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} +
+ {% include 'netbox_librenms_plugin/_vlan_sync.html' %}
+ {% endif %} + {% endwith %}
+ + {% else %}
@@ -552,6 +595,57 @@
Device Information Sync
+{% if mismatched_device %} + +
{{ librenms_device_id }}
Hostname + {% if librenms_device_hostname and librenms_device_hostname != "-" %} + {{ librenms_device_hostname }} + {% else %} + - + {% endif %} +
sysName + {% if sysName %} + {{ sysName }} + {% else %} + - + {% endif %} +
+ Serial + {% if object.serial and librenms_device_serial != object.serial %} + + + + {% endif %} + + {{ librenms_device_serial }} + {% if object.serial and librenms_device_serial == object.serial %} + + + + {% endif %} +
+ + + + + + + + + + + + + + + + + + + + + + + + +
NetBoxLibreNMS
Name / sysName{{ object.name|default:'-' }}{{ sysName|default:'-' }}
IP{{ object.primary_ip.address.ip|default:'-' }}{{ librenms_device_ip|default:'-' }}
DNS / Hostname{{ netbox_dns_name|default:'-' }}{{ librenms_device_hostname|default:'-' }}
+

Please review the details to + ensure the correct device is being used for LibreNMS sync.

+
+ +
+
+ +{% endif %} + {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html index 6117e5718b..557eacc06a 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html @@ -153,8 +153,13 @@

- Configure default naming preferences for imported devices. These settings apply to all device imports but can be overridden during individual imports. + Configure default naming preferences for imported devices.

+
+ + User preferences: These defaults apply to users who have not yet changed their own toggle settings on the import page. Once a user changes a toggle, their personal preference is saved and takes priority over these defaults. + Saving these settings will also update your own preferences to match. +
diff --git a/netbox_librenms_plugin/tests/test_background_jobs.py b/netbox_librenms_plugin/tests/test_background_jobs.py index 372da26e95..13dca842d8 100644 --- a/netbox_librenms_plugin/tests/test_background_jobs.py +++ b/netbox_librenms_plugin/tests/test_background_jobs.py @@ -15,11 +15,13 @@ class TestShouldUseBackgroundJob: """Test background job decision logic.""" def test_checkbox_checked_returns_true(self): - """When use_background_job form field is True, return True.""" + """When use_background_job form field is True, return True for superusers.""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView view = LibreNMSImportView() view._filter_form_data = {"use_background_job": True} + view.request = MagicMock() + view.request.user.is_superuser = True assert view.should_use_background_job() is True @@ -29,27 +31,45 @@ def test_checkbox_unchecked_returns_false(self): view = LibreNMSImportView() view._filter_form_data = {"use_background_job": False} + view.request = MagicMock() + view.request.user.is_superuser = True assert view.should_use_background_job() is False def test_default_when_field_missing(self): - """When field is missing, default to True.""" + """When field is missing, default to True for superusers.""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView view = LibreNMSImportView() view._filter_form_data = {"some_other_field": "value"} + view.request = MagicMock() + view.request.user.is_superuser = True assert view.should_use_background_job() is True def test_empty_form_data_returns_default(self): - """Empty form data returns default True.""" + """Empty form data returns default True for superusers.""" from netbox_librenms_plugin.views.imports.list import LibreNMSImportView view = LibreNMSImportView() view._filter_form_data = {} + view.request = MagicMock() + view.request.user.is_superuser = True assert view.should_use_background_job() is True + def test_non_superuser_always_returns_false(self): + """Non-superuser users always get synchronous mode.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {"use_background_job": True} + view.request = MagicMock() + view.request.user.is_superuser = False + + # Even when checkbox is True, non-superusers get False + assert view.should_use_background_job() is False + def create_mock_job_runner(job_class, job_pk=123): """Create a mock job runner instance without invoking real __init__.""" diff --git a/netbox_librenms_plugin/tests/test_interface_vlan_sync.py b/netbox_librenms_plugin/tests/test_interface_vlan_sync.py new file mode 100644 index 0000000000..04a7503bb1 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_interface_vlan_sync.py @@ -0,0 +1,563 @@ +""" +Tests for interface VLAN sync functionality (Phase 2). + +Tests cover: +- VlanAssignmentMixin methods +- Port VLAN enrichment +- VLAN sync action +""" + +from unittest.mock import MagicMock, patch + +# Import the autouse fixture from helpers +pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + +class TestVlanAssignmentMixin: + """Tests for VlanAssignmentMixin methods.""" + + def test_get_vlan_groups_for_device_includes_site_scoped(self, mock_librenms_config): + """Test that VLAN groups scoped to device's site are included.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + # Create mock device with site + mock_device = MagicMock() + mock_device.site = MagicMock() + mock_device.site.pk = 1 + mock_device.site.region = None + mock_device.site.group = None + mock_device.location = None + mock_device.rack = None + + # Mock the VLAN group query + mock_site_group = MagicMock() + mock_site_group.name = "Site VLANs" + mock_site_group.pk = 10 + + with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope: + mock_get_scope.return_value = [mock_site_group] + with patch("ipam.models.VLANGroup") as mock_vlan_group_class: + mock_vlan_group_class.objects.filter.return_value = [] + + mixin.get_vlan_groups_for_device(mock_device) + + # Verify site scope was queried + assert mock_get_scope.called + + def test_get_vlan_groups_for_device_includes_global(self, mock_librenms_config): + """Test that global VLAN groups (no scope) are included.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + # Create mock device with no location context + mock_device = MagicMock() + mock_device.site = None + mock_device.location = None + mock_device.rack = None + + with patch.object(mixin, "_get_vlan_groups_for_scope") as mock_get_scope: + mock_get_scope.return_value = [] + with patch("ipam.models.VLANGroup") as mock_vlan_group_class: + mock_global_group = MagicMock() + mock_global_group.name = "Global VLANs" + mock_global_group.pk = 20 + mock_vlan_group_class.objects.filter.return_value = [mock_global_group] + + mixin.get_vlan_groups_for_device(mock_device) + + # Verify global scope was queried + mock_vlan_group_class.objects.filter.assert_called_with(scope_type__isnull=True) + + def test_select_most_specific_group_prefers_rack(self, mock_librenms_config): + """Test that rack-scoped groups are preferred over site-scoped.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + # Create mock device with rack + mock_device = MagicMock() + mock_device.rack = MagicMock() + mock_device.rack.pk = 1 + mock_device.site = MagicMock() + mock_device.site.pk = 2 + mock_device.site.region = None + mock_device.site.group = None + mock_device.location = None + + # Create mock groups with different scopes + mock_rack_group = MagicMock() + mock_rack_group.scope_type = MagicMock() + mock_rack_group.scope_type.pk = 100 # Rack content type + mock_rack_group.scope_id = 1 + + mock_site_group = MagicMock() + mock_site_group.scope_type = MagicMock() + mock_site_group.scope_type.pk = 101 # Site content type + mock_site_group.scope_id = 2 + + with patch("django.contrib.contenttypes.models.ContentType") as mock_ct: + # Mock ContentType lookups + mock_ct.objects.get_for_model.side_effect = lambda model: MagicMock(pk=100 if "Rack" in str(model) else 101) + + result = mixin._select_most_specific_group([mock_rack_group, mock_site_group], mock_device) + + # Rack-scoped should be preferred + assert result == mock_rack_group + + def test_select_most_specific_group_returns_none_for_ambiguous(self, mock_librenms_config): + """Test that None is returned when multiple groups have same priority.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + # Create mock device + mock_device = MagicMock() + mock_device.site = MagicMock() + mock_device.site.pk = 1 + mock_device.site.region = None + mock_device.site.group = None + mock_device.rack = None + mock_device.location = None + + # Create two groups with same scope (both site-scoped to same site) + mock_group1 = MagicMock() + mock_group1.scope_type = MagicMock() + mock_group1.scope_type.pk = 101 + mock_group1.scope_id = 1 + + mock_group2 = MagicMock() + mock_group2.scope_type = MagicMock() + mock_group2.scope_type.pk = 101 + mock_group2.scope_id = 1 + + with patch("django.contrib.contenttypes.models.ContentType") as mock_ct: + mock_ct.objects.get_for_model.return_value = MagicMock(pk=101) + + result = mixin._select_most_specific_group([mock_group1, mock_group2], mock_device) + + # Ambiguous - should return None + assert result is None + + def test_get_ancestors_returns_hierarchy(self, mock_librenms_config): + """Test that _get_ancestors returns full parent chain.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + # Create mock location hierarchy + mock_grandparent = MagicMock() + mock_grandparent.parent = None + + mock_parent = MagicMock() + mock_parent.parent = mock_grandparent + + mock_location = MagicMock() + mock_location.parent = mock_parent + + ancestors = mixin._get_ancestors(mock_location) + + assert len(ancestors) == 3 + assert ancestors[0] == mock_location + assert ancestors[1] == mock_parent + assert ancestors[2] == mock_grandparent + + def test_find_vlan_in_group_prefers_specified_group(self, mock_librenms_config): + """Test that _find_vlan_in_group prefers the specified group.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_vlan_in_group = MagicMock() + mock_vlan_global = MagicMock() + + lookup_maps = { + "vid_group_to_vlan": { + (100, 5): mock_vlan_in_group, + (100, None): mock_vlan_global, + }, + "vid_to_vlans": { + 100: [mock_vlan_in_group, mock_vlan_global], + }, + } + + result = mixin._find_vlan_in_group(100, 5, lookup_maps) + + assert result == mock_vlan_in_group + + def test_find_vlan_in_group_falls_back_to_global(self, mock_librenms_config): + """Test that _find_vlan_in_group falls back to global VLAN.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_vlan_global = MagicMock() + + lookup_maps = { + "vid_group_to_vlan": { + (100, None): mock_vlan_global, + }, + "vid_to_vlans": { + 100: [mock_vlan_global], + }, + } + + # Request group 5 which doesn't have VLAN 100 + result = mixin._find_vlan_in_group(100, 5, lookup_maps) + + assert result == mock_vlan_global + + def test_find_vlan_in_group_returns_none_if_not_found(self, mock_librenms_config): + """Test that _find_vlan_in_group returns None if VLAN not found.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + lookup_maps = { + "vid_group_to_vlan": {}, + "vid_to_vlans": {}, + } + + result = mixin._find_vlan_in_group(999, None, lookup_maps) + + assert result is None + + +class TestPortVlanEnrichment: + """Tests for port VLAN data enrichment.""" + + pytest_plugins = ["tests.test_librenms_api_helpers"] + + @patch("requests.get") + def test_parse_port_vlan_data_access_port(self, mock_get, mock_librenms_config): + """Test parsing access port VLAN data.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = { + "port_id": 1234, + "ifName": "Gi1/0/1", + "ifDescr": "GigabitEthernet1/0/1", + "ifVlan": "100", + "ifTrunk": None, + } + + result = api.parse_port_vlan_data(port_data, "ifName") + + assert result["port_id"] == 1234 + assert result["interface_name"] == "Gi1/0/1" + assert result["mode"] == "access" + assert result["untagged_vlan"] == 100 + assert result["tagged_vlans"] == [] + + @patch("requests.get") + def test_parse_port_vlan_data_trunk_port(self, mock_get, mock_librenms_config): + """Test parsing trunk port VLAN data.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = { + "port_id": 5678, + "ifName": "Te1/1/1", + "ifDescr": "TenGigabitEthernet1/1/1", + "ifVlan": "90", + "ifTrunk": "dot1Q", + "vlans": [ + {"vlan": 90, "untagged": 1, "state": "unknown"}, + {"vlan": 50, "untagged": 0, "state": "forwarding"}, + {"vlan": 60, "untagged": 0, "state": "forwarding"}, + ], + } + + result = api.parse_port_vlan_data(port_data, "ifName") + + assert result["port_id"] == 5678 + assert result["interface_name"] == "Te1/1/1" + assert result["mode"] == "tagged" + assert result["untagged_vlan"] == 90 + assert sorted(result["tagged_vlans"]) == [50, 60] + + @patch("requests.get") + def test_parse_port_vlan_data_uses_interface_name_field(self, mock_get, mock_librenms_config): + """Test that parse_port_vlan_data respects interface_name_field parameter.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = { + "port_id": 1234, + "ifName": "Gi1/0/1", + "ifDescr": "GigabitEthernet1/0/1", + "ifVlan": "100", + "ifTrunk": None, + } + + result = api.parse_port_vlan_data(port_data, "ifDescr") + + assert result["interface_name"] == "GigabitEthernet1/0/1" + + +class TestInterfaceVlanSync: + """Tests for interface VLAN sync action.""" + + pytest_plugins = ["tests.test_librenms_api_helpers"] + + def test_update_interface_vlan_assignment_access_mode(self, mock_librenms_config): + """Test that access mode is set correctly for untagged-only ports.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_interface = MagicMock() + mock_interface.tagged_vlans = MagicMock() + + mock_vlan = MagicMock() + mock_vlan.vid = 100 + + lookup_maps = { + "vid_group_to_vlan": {(100, None): mock_vlan}, + "vid_to_vlans": {100: [mock_vlan]}, + } + + vlan_data = { + "untagged_vlan": 100, + "tagged_vlans": [], + } + + mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) + + assert mock_interface.mode == "access" + assert mock_interface.untagged_vlan == mock_vlan + mock_interface.tagged_vlans.clear.assert_called_once() + + def test_update_interface_vlan_assignment_tagged_mode(self, mock_librenms_config): + """Test that tagged mode is set for trunk ports.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_interface = MagicMock() + mock_interface.tagged_vlans = MagicMock() + + mock_vlan_100 = MagicMock() + mock_vlan_100.vid = 100 + mock_vlan_200 = MagicMock() + mock_vlan_200.vid = 200 + mock_vlan_300 = MagicMock() + mock_vlan_300.vid = 300 + + lookup_maps = { + "vid_group_to_vlan": { + (100, None): mock_vlan_100, + (200, None): mock_vlan_200, + (300, None): mock_vlan_300, + }, + "vid_to_vlans": { + 100: [mock_vlan_100], + 200: [mock_vlan_200], + 300: [mock_vlan_300], + }, + } + + vlan_data = { + "untagged_vlan": 100, + "tagged_vlans": [200, 300], + } + + mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) + + assert mock_interface.mode == "tagged" + assert mock_interface.untagged_vlan == mock_vlan_100 + mock_interface.tagged_vlans.set.assert_called_once_with([mock_vlan_200, mock_vlan_300]) + + def test_update_interface_vlan_assignment_missing_vlans(self, mock_librenms_config): + """Test that missing VLANs are tracked in result.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_interface = MagicMock() + mock_interface.tagged_vlans = MagicMock() + + # Empty lookup maps - no VLANs exist in NetBox + lookup_maps = { + "vid_group_to_vlan": {}, + "vid_to_vlans": {}, + } + + vlan_data = { + "untagged_vlan": 100, + "tagged_vlans": [200, 300], + } + + result = mixin._update_interface_vlan_assignment(mock_interface, vlan_data, None, lookup_maps) + + assert result["missing_vlans"] == [100, 200, 300] + assert mock_interface.untagged_vlan is None + mock_interface.tagged_vlans.set.assert_called_once_with([]) + + def test_update_interface_vlan_assignment_respects_group_selection(self, mock_librenms_config): + """Test that VLAN group selection is respected.""" + from netbox_librenms_plugin.views.mixins import VlanAssignmentMixin + + mixin = VlanAssignmentMixin() + + mock_interface = MagicMock() + mock_interface.tagged_vlans = MagicMock() + + mock_vlan_group1 = MagicMock() + mock_vlan_group1.vid = 100 + mock_vlan_global = MagicMock() + mock_vlan_global.vid = 100 + + lookup_maps = { + "vid_group_to_vlan": { + (100, 5): mock_vlan_group1, + (100, None): mock_vlan_global, + }, + "vid_to_vlans": { + 100: [mock_vlan_group1, mock_vlan_global], + }, + } + + vlan_data = { + "untagged_vlan": 100, + "tagged_vlans": [], + } + + # Request VLAN from group 5 + mixin._update_interface_vlan_assignment(mock_interface, vlan_data, 5, lookup_maps) + + # Should use group-specific VLAN + assert mock_interface.untagged_vlan == mock_vlan_group1 + + +class TestInterfaceCssClassGroupMatching: + """ + Tests for group-aware VLAN CSS class functions in utils.py. + + Verifies that VLAN group mismatch (same VID but different group) produces + orange (text-warning) instead of green (text-success). + """ + + # -- get_untagged_vlan_css_class -- + + def test_untagged_vid_match_group_match_returns_green(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=True) == "text-success" + + def test_untagged_vid_match_group_mismatch_returns_orange(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 60, True, [], group_matches=False) == "text-warning" + + def test_untagged_vid_differs_group_irrelevant(self, mock_librenms_config): + """Different VIDs -> text-warning regardless of group_matches.""" + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 100, True, [], group_matches=True) == "text-warning" + + def test_untagged_not_in_netbox_ignores_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 60, False, [], group_matches=True) == "text-danger" + + def test_untagged_missing_vlan_ignores_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 60, True, [60], group_matches=True) == "text-danger" + + def test_untagged_no_netbox_vlan_returns_red(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, None, True, [], group_matches=True) == "text-danger" + + def test_untagged_default_group_matches_is_true(self, mock_librenms_config): + """Without group_matches param, defaults to True (backward compat).""" + from netbox_librenms_plugin.utils import get_untagged_vlan_css_class + + assert get_untagged_vlan_css_class(60, 60, True, []) == "text-success" + + # -- get_tagged_vlan_css_class -- + + def test_tagged_vid_present_group_match_returns_green(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=True) == "text-success" + + def test_tagged_vid_present_group_mismatch_returns_orange(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {60, 100}, True, [], group_matches=False) == "text-warning" + + def test_tagged_vid_absent_group_irrelevant(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {100}, True, [], group_matches=True) == "text-danger" + + def test_tagged_not_in_netbox_ignores_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {60}, False, [], group_matches=True) == "text-danger" + + def test_tagged_missing_vlan_ignores_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {60}, True, [60], group_matches=True) == "text-danger" + + def test_tagged_default_group_matches_is_true(self, mock_librenms_config): + """Without group_matches param, defaults to True (backward compat).""" + from netbox_librenms_plugin.utils import get_tagged_vlan_css_class + + assert get_tagged_vlan_css_class(60, {60}, True, []) == "text-success" + + # -- check_vlan_group_matches -- + + def test_check_group_matches_untagged_same_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("U", 60, 5, 5, {}, 60, set()) is True + + def test_check_group_matches_untagged_different_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("U", 60, 10, 5, {}, 60, set()) is False + + def test_check_group_matches_untagged_vid_differs(self, mock_librenms_config): + """When VIDs don't match, group comparison is irrelevant -> True.""" + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("U", 60, 10, 5, {}, 100, set()) is True + + def test_check_group_matches_tagged_same_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("T", 60, 5, None, {60: 5}, None, {60}) is True + + def test_check_group_matches_tagged_different_group(self, mock_librenms_config): + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("T", 60, 10, None, {60: 5}, None, {60}) is False + + def test_check_group_matches_tagged_vid_absent(self, mock_librenms_config): + """When VID is not tagged in NetBox, group comparison irrelevant -> True.""" + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("T", 60, 10, None, {}, None, set()) is True + + def test_check_group_matches_global_to_global(self, mock_librenms_config): + """Both NetBox VLAN and selected have no group (global) -> match.""" + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("U", 60, None, None, {}, 60, set()) is True + + def test_check_group_matches_global_vs_group(self, mock_librenms_config): + """NetBox VLAN is global, selected is a specific group -> mismatch.""" + from netbox_librenms_plugin.utils import check_vlan_group_matches + + assert check_vlan_group_matches("U", 60, 5, None, {}, 60, set()) is False diff --git a/netbox_librenms_plugin/tests/test_librenms_api.py b/netbox_librenms_plugin/tests/test_librenms_api.py index d48cfb7571..5ce115f340 100644 --- a/netbox_librenms_plugin/tests/test_librenms_api.py +++ b/netbox_librenms_plugin/tests/test_librenms_api.py @@ -67,6 +67,29 @@ def test_init_missing_config_raises_valueerror(self, mock_librenms_config): with pytest.raises(ValueError): LibreNMSAPI(server_key="nonexistent") + def test_init_nonexistent_server_key_raises_keyerror(self, mock_librenms_config): + """Verify KeyError raised when specific server_key doesn't exist.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + with pytest.raises(KeyError, match="nonexistent"): + LibreNMSAPI(server_key="nonexistent") + + def test_init_default_falls_back_to_first_server(self, mock_librenms_config): + """Verify 'default' key falls back to first configured server.""" + mock_config = mock_librenms_config["mock_config"] + mock_config.return_value = { + "primary": { + "librenms_url": "https://primary.example.com", + "api_token": "primary-token", + } + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + assert api.server_key == "primary" + assert api.librenms_url == "https://primary.example.com" + # ============================================================================= # Test Class 2: Connection Testing (4 tests) @@ -589,6 +612,34 @@ def test_add_device_success(self, mock_post, mock_librenms_config): assert result[0] is True assert result[1] == "Device added successfully." + @patch("netbox_librenms_plugin.librenms_api.requests.post") + def test_add_device_snmpv1_success(self, mock_post, mock_librenms_config): + """Verify successful device addition using SNMPv1.""" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "status": "ok", + "message": "Device added successfully", + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + result = api.add_device( + data={ + "hostname": "legacy-device.example.com", + "snmp_version": "v1", + "community": "public", + } + ) + + assert result[0] is True + assert result[1] == "Device added successfully." + # Verify the payload includes correct snmpver and community + call_args = mock_post.call_args + payload = call_args.kwargs.get("json") or call_args[1].get("json") + assert payload["snmpver"] == "v1" + assert payload["community"] == "public" + @patch("netbox_librenms_plugin.librenms_api.requests.post") def test_add_device_duplicate_error(self, mock_post, mock_librenms_config): """Verify duplicate device handling.""" @@ -612,6 +663,46 @@ def test_add_device_duplicate_error(self, mock_post, mock_librenms_config): assert result[0] is False assert "Device already exists" in result[1] + @patch("netbox_librenms_plugin.librenms_api.requests.post") + def test_add_device_snmpv3_success(self, mock_post, mock_librenms_config): + """Verify successful device addition using SNMPv3 with all required fields.""" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "status": "ok", + "message": "Device added successfully", + } + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + result = api.add_device( + data={ + "hostname": "secure-device.example.com", + "snmp_version": "v3", + "authlevel": "authPriv", + "authname": "snmpuser", + "authpass": "authpassword123", + "authalgo": "SHA", + "cryptopass": "cryptopassword456", + "cryptoalgo": "AES", + } + ) + + assert result[0] is True + assert result[1] == "Device added successfully." + # Verify the payload includes correct snmpver and all v3 fields + call_args = mock_post.call_args + payload = call_args.kwargs.get("json") or call_args[1].get("json") + assert payload["snmpver"] == "v3" + assert payload["authlevel"] == "authPriv" + assert payload["authname"] == "snmpuser" + assert payload["authpass"] == "authpassword123" + assert payload["authalgo"] == "SHA" + assert payload["cryptopass"] == "cryptopassword456" + assert payload["cryptoalgo"] == "AES" + # Ensure community is NOT included for v3 + assert "community" not in payload + @patch("netbox_librenms_plugin.librenms_api.requests.patch") def test_update_device_field_success(self, mock_patch, mock_librenms_config): """Verify successful device field update.""" diff --git a/netbox_librenms_plugin/tests/test_permissions.py b/netbox_librenms_plugin/tests/test_permissions.py new file mode 100644 index 0000000000..d366965ead --- /dev/null +++ b/netbox_librenms_plugin/tests/test_permissions.py @@ -0,0 +1,953 @@ +from unittest.mock import MagicMock, patch + + +class TestLibreNMSPermissionMixin: + """Tests for permission mixin functionality.""" + + def test_has_write_permission_granted(self): + """User with change permission has write access.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + assert mixin.has_write_permission() is True + + def test_has_write_permission_denied(self): + """User without change permission lacks write access.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + + assert mixin.has_write_permission() is False + + def test_require_write_permission_allowed(self): + """User with write permission gets None (allowed to proceed).""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + result = mixin.require_write_permission() + assert result is None + + def test_require_write_permission_denied(self): + """User without write permission gets redirect response to referrer.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/some/path/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {} # Not an HTMX request + + with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: + with patch("netbox_librenms_plugin.views.mixins.messages"): + result = mixin.require_write_permission() + + mock_redirect.assert_called_once_with("/original/page/") + assert result is not None + + def test_require_write_permission_denied_htmx(self): + """HTMX request without write permission gets HX-Redirect response.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/some/path/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {"HX-Request": "true"} + + with patch("netbox_librenms_plugin.views.mixins.messages"): + result = mixin.require_write_permission() + + # Should return HttpResponse with HX-Redirect header + assert result is not None + assert result["HX-Redirect"] == "/original/page/" + + def test_require_write_permission_json_allowed(self): + """User with write permission gets None (allowed to proceed).""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + result = mixin.require_write_permission_json() + assert result is None + + def test_require_write_permission_json_denied(self): + """User without write permission gets JsonResponse with 403.""" + import json + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + + result = mixin.require_write_permission_json() + + assert result is not None + assert result.status_code == 403 + content = json.loads(result.content) + assert content["error"] == "You do not have permission to perform this action." + + def test_require_write_permission_json_custom_message(self): + """Custom error message is returned in JsonResponse.""" + import json + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + + result = mixin.require_write_permission_json(error_message="Custom denied message") + + assert result is not None + assert result.status_code == 403 + content = json.loads(result.content) + assert content["error"] == "Custom denied message" + + +class TestAPIPermissions: + """Tests for API permission class.""" + + def test_get_requires_view_permission(self): + """GET requests require view permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "GET" + request.user.has_perm.return_value = True + + assert permission.has_permission(request, None) is True + request.user.has_perm.assert_called_with(PERM_VIEW_PLUGIN) + + def test_post_requires_change_permission(self): + """POST requests require change permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "POST" + request.user.has_perm.return_value = True + + assert permission.has_permission(request, None) is True + request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) + + def test_put_requires_change_permission(self): + """PUT requests require change permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "PUT" + request.user.has_perm.return_value = True + + assert permission.has_permission(request, None) is True + request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) + + def test_delete_requires_change_permission(self): + """DELETE requests require change permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "DELETE" + request.user.has_perm.return_value = True + + assert permission.has_permission(request, None) is True + request.user.has_perm.assert_called_with(PERM_CHANGE_PLUGIN) + + def test_get_denied_without_view_permission(self): + """GET requests denied without view permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "GET" + request.user.has_perm.return_value = False + + assert permission.has_permission(request, None) is False + + def test_post_denied_without_change_permission(self): + """POST requests denied without change permission.""" + from netbox_librenms_plugin.api.views import LibreNMSPluginPermission + + permission = LibreNMSPluginPermission() + request = MagicMock() + request.method = "POST" + request.user.has_perm.return_value = False + + assert permission.has_permission(request, None) is False + + +class TestPermissionConstants: + """Tests for permission constants.""" + + def test_view_permission_constant(self): + """View permission constant is correct.""" + from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN + + assert PERM_VIEW_PLUGIN == "netbox_librenms_plugin.view_librenmssettings" + + def test_change_permission_constant(self): + """Change permission constant is correct.""" + from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN + + assert PERM_CHANGE_PLUGIN == "netbox_librenms_plugin.change_librenmssettings" + + +# ============================================================================= +# Phase 2: Object Permission Tests +# ============================================================================= + + +class TestObjectPermissionHelpers: + """Tests for Phase 2 object permission helper functions.""" + + def test_check_user_permissions_all_granted(self): + """Returns True when user has all permissions.""" + from netbox_librenms_plugin.import_utils import check_user_permissions + + user = MagicMock() + user.has_perm.return_value = True + + has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) + + assert has_all is True + assert missing == [] + assert user.has_perm.call_count == 2 + + def test_check_user_permissions_some_missing(self): + """Returns False with list of missing permissions.""" + from netbox_librenms_plugin.import_utils import check_user_permissions + + user = MagicMock() + user.has_perm.side_effect = lambda p: p != "dcim.add_interface" + + has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) + + assert has_all is False + assert missing == ["dcim.add_interface"] + + def test_check_user_permissions_all_missing(self): + """Returns False with all permissions listed as missing.""" + from netbox_librenms_plugin.import_utils import check_user_permissions + + user = MagicMock() + user.has_perm.return_value = False + + has_all, missing = check_user_permissions(user, ["dcim.add_device", "dcim.add_interface"]) + + assert has_all is False + assert "dcim.add_device" in missing + assert "dcim.add_interface" in missing + + def test_check_user_permissions_no_user(self): + """Raises PermissionDenied when user is None.""" + import pytest + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils import check_user_permissions + + with pytest.raises(PermissionDenied, match="No user context"): + check_user_permissions(None, ["dcim.add_device"]) + + def test_require_permissions_passes_when_granted(self): + """Does not raise when user has all permissions.""" + from netbox_librenms_plugin.import_utils import require_permissions + + user = MagicMock() + user.has_perm.return_value = True + + # Should not raise + require_permissions(user, ["dcim.add_device", "dcim.add_interface"], "import devices") + + def test_require_permissions_raises_on_missing(self): + """Raises PermissionDenied with descriptive message.""" + import pytest + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils import require_permissions + + user = MagicMock() + user.has_perm.return_value = False + + with pytest.raises(PermissionDenied) as exc_info: + require_permissions(user, ["dcim.add_device"], "import devices") + + # Check error message contains action description and missing permission + assert "import devices" in str(exc_info.value) + assert "dcim.add_device" in str(exc_info.value) + + def test_require_permissions_lists_multiple_missing(self): + """Error message includes all missing permissions.""" + import pytest + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils import require_permissions + + user = MagicMock() + user.has_perm.return_value = False + + with pytest.raises(PermissionDenied) as exc_info: + require_permissions( + user, + ["dcim.add_device", "dcim.add_interface"], + "import devices", + ) + + error_msg = str(exc_info.value) + assert "dcim.add_device" in error_msg + assert "dcim.add_interface" in error_msg + + +class TestNetBoxObjectPermissionMixin: + """Tests for the NetBoxObjectPermissionMixin class.""" + + def test_check_object_permissions_all_granted(self): + """Returns True when user has all object permissions.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model), ("change", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.side_effect = ["dcim.add_interface", "dcim.change_interface"] + has_all, missing = mixin.check_object_permissions("POST") + + assert has_all is True + assert missing == [] + + def test_check_object_permissions_some_missing(self): + """Returns False with missing permission strings.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.side_effect = lambda p: p != "dcim.add_interface" + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.add_interface" + has_all, missing = mixin.check_object_permissions("POST") + + assert has_all is False + assert "dcim.add_interface" in missing + + def test_check_object_permissions_no_requirements(self): + """Returns True when no permissions required for method.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.required_object_permissions = {} # No requirements + + has_all, missing = mixin.check_object_permissions("POST") + + assert has_all is True + assert missing == [] + + def test_require_object_permissions_returns_none_when_granted(self): + """Returns None when all permissions are granted.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.add_cable" + response = mixin.require_object_permissions("POST") + + assert response is None + + def test_require_object_permissions_returns_redirect_response(self): + """Returns redirect response with message when permissions missing.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/original/page/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {} # Not an HTMX request + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + with patch("netbox_librenms_plugin.views.mixins.messages") as mock_messages: + with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: + mock_get.return_value = "dcim.add_cable" + response = mixin.require_object_permissions("POST") + + assert response is not None + # Verify error message was added + mock_messages.error.assert_called_once() + error_msg = mock_messages.error.call_args[0][1] + assert "dcim.add_cable" in error_msg + # Verify redirect was called + mock_redirect.assert_called_once_with("/original/page/") + + def test_require_object_permissions_htmx_returns_hx_redirect(self): + """HTMX request returns HX-Redirect header when permissions missing.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/original/page/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {"HX-Request": "true"} + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + with patch("netbox_librenms_plugin.views.mixins.messages"): + mock_get.return_value = "dcim.add_cable" + response = mixin.require_object_permissions("POST") + + assert response is not None + assert response["HX-Redirect"] == "/original/page/" + + def test_require_object_permissions_json_allowed(self): + """Returns None when all object permissions are granted.""" + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("delete", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.delete_interface" + response = mixin.require_object_permissions_json("POST") + + assert response is None + + def test_require_object_permissions_json_denied(self): + """Returns JsonResponse with 403 when object permissions missing.""" + import json + + from netbox_librenms_plugin.views.mixins import NetBoxObjectPermissionMixin + + mixin = NetBoxObjectPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("delete", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.delete_interface" + response = mixin.require_object_permissions_json("POST") + + assert response is not None + assert response.status_code == 403 + content = json.loads(response.content) + assert "dcim.delete_interface" in content["error"] + + def test_require_all_permissions_allowed(self): + """Returns None when both write and object permissions granted.""" + from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): + pass + + mixin = TestView() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("change", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.change_device" + response = mixin.require_all_permissions("POST") + + assert response is None + + def test_require_all_permissions_denied_write(self): + """Returns error when write permission denied (doesn't check object perms).""" + from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): + pass + + mixin = TestView() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/original/page/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {} + + mixin.required_object_permissions = {"POST": []} + + with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: + with patch("netbox_librenms_plugin.views.mixins.messages"): + response = mixin.require_all_permissions("POST") + + assert response is not None + mock_redirect.assert_called_once_with("/original/page/") + + def test_require_all_permissions_denied_object(self): + """Returns error when object permissions denied (write passes).""" + from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): + pass + + mixin = TestView() + mixin.request = MagicMock() + # has_write_permission passes, but object perms fail + mixin.request.user.has_perm.side_effect = lambda p: p == "netbox_librenms_plugin.change_librenmssettings" + mixin.request.path = "/original/page/" + mixin.request.META = {"HTTP_REFERER": "/original/page/"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {} + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("add", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + with patch("netbox_librenms_plugin.views.mixins.messages"): + with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: + mock_get.return_value = "dcim.add_device" + response = mixin.require_all_permissions("POST") + + assert response is not None + mock_redirect.assert_called_once() + + def test_require_all_permissions_json_allowed(self): + """Returns None when both write and object permissions granted (JSON variant).""" + from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): + pass + + mixin = TestView() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = True + + mock_model = MagicMock() + mixin.required_object_permissions = { + "POST": [("delete", mock_model)], + } + + with patch("netbox_librenms_plugin.views.mixins.get_permission_for_model") as mock_get: + mock_get.return_value = "dcim.delete_interface" + response = mixin.require_all_permissions_json("POST") + + assert response is None + + def test_require_all_permissions_json_denied_write(self): + """Returns JSON 403 when write permission denied (JSON variant).""" + import json + + from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + ) + + class TestView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin): + pass + + mixin = TestView() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + + response = mixin.require_all_permissions_json("POST") + + assert response is not None + assert response.status_code == 403 + content = json.loads(response.content) + assert "error" in content + + +class TestBulkImportPermissions: + """Tests for permission checks in bulk import functions.""" + + @patch("netbox_librenms_plugin.import_utils.require_permissions") + @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI") + def test_bulk_import_devices_checks_permissions(self, mock_api_class, mock_require): + """bulk_import_devices_shared calls require_permissions.""" + from netbox_librenms_plugin.import_utils import bulk_import_devices_shared + + user = MagicMock() + mock_api = MagicMock() + mock_api_class.return_value = mock_api + + # Set up API to return empty device so loop completes quickly + mock_api.get_device_info.return_value = (False, None) + + bulk_import_devices_shared( + device_ids=[1], + user=user, + server_key="default", + ) + + mock_require.assert_called_once() + call_args = mock_require.call_args + assert user == call_args[0][0] + assert "dcim.add_device" in call_args[0][1] + assert "dcim.add_interface" in call_args[0][1] + + @patch("netbox_librenms_plugin.import_utils.require_permissions") + @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI") + def test_bulk_import_devices_extracts_user_from_job(self, mock_api_class, mock_require): + """bulk_import_devices_shared extracts user from job if not provided.""" + from netbox_librenms_plugin.import_utils import bulk_import_devices_shared + + job_user = MagicMock() + job = MagicMock() + job.job.user = job_user + + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_device_info.return_value = (False, None) + + bulk_import_devices_shared( + device_ids=[1], + job=job, + server_key="default", + ) + + mock_require.assert_called_once() + call_args = mock_require.call_args + assert job_user == call_args[0][0] + + @patch("netbox_librenms_plugin.import_utils.require_permissions") + def test_bulk_import_vms_checks_permissions(self, mock_require): + """bulk_import_vms calls require_permissions.""" + from netbox_librenms_plugin.import_utils import bulk_import_vms + + user = MagicMock() + api = MagicMock() + api.server_key = "default" + + # Empty vm_imports to complete quickly + bulk_import_vms( + vm_imports={}, + api=api, + user=user, + ) + + mock_require.assert_called_once() + call_args = mock_require.call_args + assert user == call_args[0][0] + assert "virtualization.add_virtualmachine" in call_args[0][1] + + @patch("netbox_librenms_plugin.import_utils.require_permissions") + def test_bulk_import_vms_extracts_user_from_job(self, mock_require): + """bulk_import_vms extracts user from job if not provided.""" + from netbox_librenms_plugin.import_utils import bulk_import_vms + + job_user = MagicMock() + job = MagicMock() + job.job.user = job_user + + api = MagicMock() + api.server_key = "default" + + bulk_import_vms( + vm_imports={}, + api=api, + job=job, + ) + + mock_require.assert_called_once() + call_args = mock_require.call_args + assert job_user == call_args[0][0] + + +class TestBulkImportPermissionDenied: + """Tests for permission denied behavior in bulk import.""" + + @patch("netbox_librenms_plugin.import_utils.check_user_permissions") + def test_bulk_import_devices_raises_on_missing_permissions(self, mock_check): + """bulk_import_devices_shared raises PermissionDenied when permissions missing.""" + import pytest + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils import bulk_import_devices_shared + + mock_check.return_value = (False, ["dcim.add_device"]) + + user = MagicMock() + + with pytest.raises(PermissionDenied): + bulk_import_devices_shared( + device_ids=[1], + user=user, + server_key="default", + ) + + @patch("netbox_librenms_plugin.import_utils.check_user_permissions") + def test_bulk_import_vms_raises_on_missing_permissions(self, mock_check): + """bulk_import_vms raises PermissionDenied when permissions missing.""" + import pytest + from django.core.exceptions import PermissionDenied + + from netbox_librenms_plugin.import_utils import bulk_import_vms + + mock_check.return_value = (False, ["virtualization.add_virtualmachine"]) + + user = MagicMock() + api = MagicMock() + + with pytest.raises(PermissionDenied): + bulk_import_vms( + vm_imports={1: {"cluster_id": 1}}, + api=api, + user=user, + ) + + +class TestSafeRedirectUrl: + """Tests for the _get_safe_redirect_url helper.""" + + def test_internal_referrer_is_accepted(self): + """Internal referrer URL is returned when host matches.""" + from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url + + request = MagicMock() + request.META = {"HTTP_REFERER": "http://testserver/some/page/"} + request.get_host.return_value = "testserver" + request.is_secure.return_value = False + request.path = "/fallback/" + + result = _get_safe_redirect_url(request) + assert result == "http://testserver/some/page/" + + def test_external_referrer_is_rejected(self): + """External referrer URL is rejected, falls back to request.path.""" + from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url + + request = MagicMock() + request.META = {"HTTP_REFERER": "http://evil.com/attack"} + request.get_host.return_value = "testserver" + request.is_secure.return_value = False + request.path = "/safe/fallback/" + + result = _get_safe_redirect_url(request) + assert result == "/safe/fallback/" + + def test_no_referrer_falls_back_to_path(self): + """Missing referrer falls back to request.path.""" + from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url + + request = MagicMock() + request.META = {} + request.path = "/current/page/" + + result = _get_safe_redirect_url(request) + assert result == "/current/page/" + + def test_no_referrer_no_path_falls_back_to_slash(self): + """Missing referrer and no path attribute falls back to '/'.""" + from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url + + request = MagicMock(spec=[]) # No attributes at all + request.META = {} + + result = _get_safe_redirect_url(request) + assert result == "/" + + def test_relative_referrer_is_accepted(self): + """Relative referrer path is accepted (no host to mismatch).""" + from netbox_librenms_plugin.views.mixins import _get_safe_redirect_url + + request = MagicMock() + request.META = {"HTTP_REFERER": "/original/page/"} + request.get_host.return_value = "testserver" + request.is_secure.return_value = False + request.path = "/fallback/" + + result = _get_safe_redirect_url(request) + assert result == "/original/page/" + + def test_write_permission_denied_rejects_external_referrer(self): + """Write permission denial with external referrer falls back to request.path.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/safe/page/" + mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {} + + with patch("netbox_librenms_plugin.views.mixins.redirect") as mock_redirect: + with patch("netbox_librenms_plugin.views.mixins.messages"): + mixin.require_write_permission() + + mock_redirect.assert_called_once_with("/safe/page/") + + def test_htmx_rejects_external_referrer(self): + """HTMX request with external referrer uses fallback in HX-Redirect.""" + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + mixin = LibreNMSPermissionMixin() + mixin.request = MagicMock() + mixin.request.user.has_perm.return_value = False + mixin.request.path = "/safe/page/" + mixin.request.META = {"HTTP_REFERER": "http://evil.com/steal"} + mixin.request.get_host.return_value = "testserver" + mixin.request.is_secure.return_value = False + mixin.request.headers = {"HX-Request": "true"} + + with patch("netbox_librenms_plugin.views.mixins.messages"): + result = mixin.require_write_permission() + + assert result["HX-Redirect"] == "/safe/page/" + + +class TestBulkImportVCPermission: + """Tests that bulk import checks virtualchassis permission.""" + + @patch("netbox_librenms_plugin.import_utils.require_permissions") + @patch("netbox_librenms_plugin.import_utils.LibreNMSAPI") + def test_bulk_import_devices_checks_vc_permission(self, mock_api_class, mock_require): + """bulk_import_devices_shared includes dcim.add_virtualchassis in required perms.""" + from netbox_librenms_plugin.import_utils import bulk_import_devices_shared + + user = MagicMock() + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_device_info.return_value = (False, None) + + bulk_import_devices_shared( + device_ids=[1], + user=user, + server_key="default", + ) + + mock_require.assert_called_once() + call_args = mock_require.call_args + assert "dcim.add_virtualchassis" in call_args[0][1] + + +class TestObjectTypeValidation: + """Tests that get_required_permissions_for_object_type validates object_type.""" + + def test_sync_interfaces_device_type(self): + """SyncInterfacesView returns correct perms for device type.""" + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + view = SyncInterfacesView() + perms = view.get_required_permissions_for_object_type("device") + assert len(perms) == 2 + + def test_sync_interfaces_vm_type(self): + """SyncInterfacesView returns correct perms for virtualmachine type.""" + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + view = SyncInterfacesView() + perms = view.get_required_permissions_for_object_type("virtualmachine") + assert len(perms) == 2 + + def test_sync_interfaces_invalid_type_raises_404(self): + """SyncInterfacesView raises Http404 for invalid object type.""" + import pytest + from django.http import Http404 + + from netbox_librenms_plugin.views.sync.interfaces import SyncInterfacesView + + view = SyncInterfacesView() + with pytest.raises(Http404): + view.get_required_permissions_for_object_type("invalid") + + def test_delete_interfaces_device_type(self): + """DeleteNetBoxInterfacesView returns correct perms for device type.""" + from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView + + view = DeleteNetBoxInterfacesView() + perms = view.get_required_permissions_for_object_type("device") + assert len(perms) == 1 + + def test_delete_interfaces_vm_type(self): + """DeleteNetBoxInterfacesView returns correct perms for virtualmachine type.""" + from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView + + view = DeleteNetBoxInterfacesView() + perms = view.get_required_permissions_for_object_type("virtualmachine") + assert len(perms) == 1 + + def test_delete_interfaces_invalid_type_raises_404(self): + """DeleteNetBoxInterfacesView raises Http404 for invalid object type.""" + import pytest + from django.http import Http404 + + from netbox_librenms_plugin.views.sync.interfaces import DeleteNetBoxInterfacesView + + view = DeleteNetBoxInterfacesView() + with pytest.raises(Http404): + view.get_required_permissions_for_object_type("invalid") diff --git a/netbox_librenms_plugin/tests/test_sync_view_mismatch.py b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py new file mode 100644 index 0000000000..e59ab6909c --- /dev/null +++ b/netbox_librenms_plugin/tests/test_sync_view_mismatch.py @@ -0,0 +1,320 @@ +"""Tests for device mismatch detection in get_librenms_device_info. + +Covers the identity cross-matching logic that determines whether a +mismatched_device warning banner is shown on the LibreNMS Sync page. + +Match rule: mismatch is False when ANY NetBox identity (device name, +primary IP, DNS name) matches ANY LibreNMS identity (sysName, hostname, ip). +""" + +from unittest.mock import MagicMock, patch + + +def _make_view(librenms_id, device_info, librenms_url="https://librenms.example.com"): + """Create a minimal BaseLibreNMSSyncView instance with mocked dependencies.""" + from netbox_librenms_plugin.views.base.librenms_sync_view import BaseLibreNMSSyncView + + view = object.__new__(BaseLibreNMSSyncView) + view.librenms_id = librenms_id + api = MagicMock() + api.librenms_url = librenms_url + api.get_device_info.return_value = (True, device_info) + api.get_device_inventory.return_value = (True, []) + view._librenms_api = api + return view + + +def _make_obj(name, primary_ip=None, dns_name=None, virtual_chassis=None, cf=None): + """Create a mock NetBox device object.""" + obj = MagicMock() + obj.name = name + obj.cf = cf or {} + if primary_ip: + obj.primary_ip = MagicMock() + obj.primary_ip.address.ip = primary_ip + obj.primary_ip.dns_name = dns_name or "" + else: + obj.primary_ip = None + obj.virtual_chassis = virtual_chassis + return obj + + +class TestMismatchDetection: + """Tests for identity cross-matching logic.""" + + # -- No device / API failure ------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_no_librenms_id_returns_not_found(self, mock_hw): + """No librenms_id means device is not found.""" + view = _make_view(librenms_id=None, device_info=None) + result = view.get_librenms_device_info(_make_obj("sw01")) + + assert result["found_in_librenms"] is False + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_api_failure_returns_not_found(self, mock_hw): + """API failure (success=False) means device is not found.""" + view = _make_view(librenms_id=42, device_info=None) + view.librenms_api.get_device_info.return_value = (False, None) + result = view.get_librenms_device_info(_make_obj("sw01")) + + assert result["found_in_librenms"] is False + assert result["mismatched_device"] is False + + # -- Name matches ------------------------------------------------------ + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_exact_sysname_match(self, mock_hw): + """NetBox name matches LibreNMS sysName (case-insensitive).""" + view = _make_view(42, {"sysName": "SW01", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_netbox_name_matches_librenms_hostname(self, mock_hw): + """NetBox name matches LibreNMS hostname field.""" + view = _make_view(42, {"sysName": "something-else", "hostname": "sw01", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_fqdn_match(self, mock_hw): + """Full FQDN match -- no mismatch.""" + view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + # -- IP matches -------------------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_netbox_ip_matches_librenms_ip(self, mock_hw): + """NetBox primary IP matches LibreNMS IP -- no mismatch.""" + view = _make_view(42, {"sysName": "different", "ip": "10.0.0.1"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_netbox_ip_matches_librenms_hostname_ip(self, mock_hw): + """LibreNMS hostname is an IP that matches NetBox primary IP.""" + view = _make_view(42, {"sysName": "different", "hostname": "10.0.0.1", "ip": "10.0.0.1"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + # -- DNS name matches -------------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_dns_name_matches_sysname(self, mock_hw): + """NetBox DNS name matches LibreNMS sysName.""" + view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_dns_name_matches_librenms_hostname(self, mock_hw): + """NetBox DNS name matches LibreNMS hostname field.""" + view = _make_view(42, {"sysName": "something", "hostname": "sw01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1", dns_name="sw01.example.net") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + # -- Mismatches -------------------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_completely_different_is_mismatch(self, mock_hw): + """No identities overlap -- mismatch.""" + view = _make_view(42, {"sysName": "router-01", "hostname": "router-01.corp", "ip": "10.0.0.2"}) + obj = _make_obj("switch-05", primary_ip="10.0.0.1", dns_name="switch-05.corp") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is True + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_short_vs_fqdn_matches_via_domain_strip(self, mock_hw): + """Short name vs FQDN -- matches after domain stripping.""" + view = _make_view(42, {"sysName": "sw01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_fqdn_domain_differs_matches_via_domain_strip(self, mock_hw): + """Different FQDN domains -- matches because domain-stripped + LibreNMS short name 'sw01' matches NetBox FQDN split 'sw01'. + + NetBox name 'sw01.example.net' is compared as-is (no stripping), + but the LibreNMS domain-stripped 'sw01' does NOT appear in the + NetBox identities since NetBox names are not domain-stripped. + However, both sides share the short name via NetBox raw name + normalization β€” actually NetBox keeps the full name. + """ + view = _make_view(42, {"sysName": "sw01.other.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01.example.net", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + # NetBox identities: {"sw01.example.net", "10.0.0.1"} + # LibreNMS identities: {"sw01.other.net", "sw01", "10.0.0.2"} + # No overlap β†’ mismatch + assert result["mismatched_device"] is True + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_no_netbox_name_no_ip_match(self, mock_hw): + """No NetBox name and IPs differ -- mismatch.""" + view = _make_view(42, {"sysName": "sw01", "ip": "10.0.0.2"}) + obj = _make_obj(None, primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is True + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_no_librenms_sysname_no_match(self, mock_hw): + """No sysName, no hostname, IPs differ -- mismatch.""" + view = _make_view(42, {"sysName": None, "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is True + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_no_identities_at_all(self, mock_hw): + """Both sides have no identities -- mismatch (cannot confirm).""" + view = _make_view(42, {"sysName": None, "ip": None}) + obj = _make_obj(None, primary_ip=None) + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is True + + # -- Virtual Chassis --------------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_vc_suffix_stripped(self, mock_hw): + """VC member suffix ' (1)' is stripped before comparison.""" + view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"}) + obj = _make_obj("switch-1 (1)", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_vc_different_name_is_mismatch(self, mock_hw): + """VC member with different name after suffix strip -- mismatch.""" + vc = MagicMock() + view = _make_view(42, {"sysName": "switch-1", "ip": "10.0.0.2"}) + obj = _make_obj("switch-2 (2)", primary_ip="10.0.0.1", virtual_chassis=vc, cf={"librenms_id": 42}) + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + assert result["mismatched_device"] is True + + # -- found_in_librenms always True with valid ID ----------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_found_in_librenms_always_true_with_valid_id(self, mock_hw): + """found_in_librenms is True even when identities mismatch.""" + view = _make_view(42, {"sysName": "totally-different", "ip": "10.0.0.2"}) + obj = _make_obj("my-device", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["found_in_librenms"] is True + + # -- Domain stripping -------------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_domain_strip_hostname(self, mock_hw): + """LibreNMS hostname FQDN stripped to short name matches NetBox name.""" + view = _make_view(42, {"sysName": "other", "hostname": "sw01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_domain_strip_sysname(self, mock_hw): + """LibreNMS sysName FQDN stripped to short name matches NetBox name.""" + view = _make_view(42, {"sysName": "sw01.corp.local", "ip": "10.0.0.2"}) + obj = _make_obj("sw01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + def test_domain_strip_no_false_positive(self, mock_hw): + """Domain stripping doesn't cause false match when short names differ.""" + view = _make_view(42, {"sysName": "router01.example.net", "ip": "10.0.0.2"}) + obj = _make_obj("switch01", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is True + + # -- VC pattern stripping ---------------------------------------------- + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects") + def test_vc_pattern_strip_default(self, mock_settings_qs, mock_hw): + """Default VC pattern '-M{position}' is stripped from NetBox name.""" + settings_obj = MagicMock() + settings_obj.vc_member_name_pattern = "-M{position}" + mock_settings_qs.first.return_value = settings_obj + + view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"}) + obj = _make_obj("switch01-M2", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects") + def test_vc_pattern_strip_custom(self, mock_settings_qs, mock_hw): + """Custom VC pattern '-SW{position}' is stripped from NetBox name.""" + settings_obj = MagicMock() + settings_obj.vc_member_name_pattern = "-SW{position}" + mock_settings_qs.first.return_value = settings_obj + + view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"}) + obj = _make_obj("switch01-SW3", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is False + + @patch("netbox_librenms_plugin.views.base.librenms_sync_view.match_librenms_hardware_to_device_type") + @patch("netbox_librenms_plugin.models.LibreNMSSettings.objects") + def test_vc_pattern_no_match_leaves_name(self, mock_settings_qs, mock_hw): + """VC pattern doesn't match -- name unchanged, still mismatched.""" + settings_obj = MagicMock() + settings_obj.vc_member_name_pattern = "-M{position}" + mock_settings_qs.first.return_value = settings_obj + + view = _make_view(42, {"sysName": "switch01", "ip": "10.0.0.2"}) + obj = _make_obj("switch99", primary_ip="10.0.0.1") + result = view.get_librenms_device_info(obj) + + assert result["mismatched_device"] is True diff --git a/netbox_librenms_plugin/tests/test_utils.py b/netbox_librenms_plugin/tests/test_utils.py index cca259d18a..96065ab760 100644 --- a/netbox_librenms_plugin/tests/test_utils.py +++ b/netbox_librenms_plugin/tests/test_utils.py @@ -4,6 +4,7 @@ platform matching, and conversion helper functions. """ +import json from unittest.mock import MagicMock, patch # ============================================================================= @@ -427,8 +428,140 @@ def test_get_interface_name_field_from_config(self, mock_plugin_config): mock_request = MagicMock() mock_request.GET = {} mock_request.POST = {} + mock_request.user.config.get.return_value = None result = get_interface_name_field(mock_request) assert result == "ifAlias" mock_plugin_config.assert_called_with("netbox_librenms_plugin", "interface_name_field") + + @patch("netbox_librenms_plugin.utils.get_plugin_config") + def test_get_interface_name_field_from_user_pref(self, mock_plugin_config): + """Falls back to user preference before plugin config.""" + from netbox_librenms_plugin.utils import get_interface_name_field + + mock_request = MagicMock() + mock_request.GET = {} + mock_request.POST = {} + mock_request.user.config.get.return_value = "ifName" + + result = get_interface_name_field(mock_request) + + assert result == "ifName" + mock_plugin_config.assert_not_called() + + @patch("netbox_librenms_plugin.utils.get_plugin_config") + def test_get_interface_name_field_persists_to_user_pref(self, mock_plugin_config): + """Explicit GET param should be persisted to user preferences.""" + from netbox_librenms_plugin.utils import get_interface_name_field + + mock_request = MagicMock() + mock_request.GET = {"interface_name_field": "ifDescr"} + mock_request.POST = {} + + result = get_interface_name_field(mock_request) + + assert result == "ifDescr" + mock_request.user.config.set.assert_called_once_with( + "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True + ) + + +# ============================================================================= +# TestSaveUserPrefView - 6 tests +# ============================================================================= + + +class TestSaveUserPrefView: + """Test SaveUserPrefView endpoint for JS-driven preference persistence.""" + + def _make_request(self, body, has_perm=True): + """Create a mock POST request with JSON body.""" + request = MagicMock() + request.body = json.dumps(body).encode() + request.user.has_perm.return_value = has_perm + request.user.config = MagicMock() + request.method = "POST" + return request + + def test_save_valid_boolean_pref(self): + """Saving a valid boolean preference returns ok.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + + view = SaveUserPrefView() + request = self._make_request({"key": "use_sysname", "value": True}) + view.request = request + + response = view.post(request) + + assert response.status_code == 200 + data = json.loads(response.content) + assert data["status"] == "ok" + request.user.config.set.assert_called_once_with("plugins.netbox_librenms_plugin.use_sysname", True, commit=True) + + def test_save_string_pref(self): + """Saving interface_name_field string value works.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + + view = SaveUserPrefView() + request = self._make_request({"key": "interface_name_field", "value": "ifDescr"}) + view.request = request + + response = view.post(request) + + assert response.status_code == 200 + request.user.config.set.assert_called_once_with( + "plugins.netbox_librenms_plugin.interface_name_field", "ifDescr", commit=True + ) + + def test_reject_invalid_key(self): + """Invalid preference key returns 400.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + + view = SaveUserPrefView() + request = self._make_request({"key": "malicious_key", "value": True}) + view.request = request + + response = view.post(request) + + assert response.status_code == 400 + data = json.loads(response.content) + assert "Invalid preference key" in data["error"] + request.user.config.set.assert_not_called() + + def test_reject_invalid_json(self): + """Invalid JSON body returns 400.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + + view = SaveUserPrefView() + request = MagicMock() + request.body = b"not valid json" + view.request = request + + response = view.post(request) + + assert response.status_code == 400 + data = json.loads(response.content) + assert "Invalid JSON" in data["error"] + + def test_save_false_value(self): + """Saving False for a toggle works correctly.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + + view = SaveUserPrefView() + request = self._make_request({"key": "strip_domain", "value": False}) + view.request = request + + response = view.post(request) + + assert response.status_code == 200 + request.user.config.set.assert_called_once_with( + "plugins.netbox_librenms_plugin.strip_domain", False, commit=True + ) + + def test_uses_permission_mixin(self): + """SaveUserPrefView inherits from LibreNMSPermissionMixin.""" + from netbox_librenms_plugin.views.imports.actions import SaveUserPrefView + from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + + assert issubclass(SaveUserPrefView, LibreNMSPermissionMixin) diff --git a/netbox_librenms_plugin/tests/test_vlan_sync.py b/netbox_librenms_plugin/tests/test_vlan_sync.py new file mode 100644 index 0000000000..caaced6f91 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_vlan_sync.py @@ -0,0 +1,461 @@ +""" +Tests for VLAN sync feature. + +Tests cover: +- LibreNMS VLAN API methods +- VLAN mode detection logic +- VLAN comparison logic +- Port VLAN data parsing +""" + +from unittest.mock import MagicMock, patch + +# Import the autouse fixture from helpers +pytest_plugins = ["netbox_librenms_plugin.tests.test_librenms_api_helpers"] + + +# ============================================ +# TEST DATA FIXTURES +# ============================================ + +# Sample LibreNMS VLAN response (from /resources/vlans endpoint) +# Note: This endpoint includes vlan_id and device_id, unlike /devices/{id}/vlans +MOCK_DEVICE_VLANS = { + "status": "ok", + "vlans": [ + { + "vlan_id": 101, + "device_id": 123, + "vlan_vlan": 1, + "vlan_name": "default", + "vlan_type": "ethernet", + "vlan_state": 1, + "vlan_domain": 1, + }, + { + "vlan_id": 102, + "device_id": 123, + "vlan_vlan": 50, + "vlan_name": "ORG_DATA", + "vlan_type": "ethernet", + "vlan_state": 1, + "vlan_domain": 1, + }, + { + "vlan_id": 103, + "device_id": 123, + "vlan_vlan": 60, + "vlan_name": "ORG_VOICE", + "vlan_type": "ethernet", + "vlan_state": 1, + "vlan_domain": 1, + }, + ], + "count": 3, +} + +# Sample port VLAN info response (bulk call) +MOCK_PORT_VLAN_INFO = { + "status": "ok", + "ports": [ + {"port_id": 114184, "ifName": "Gi1/0/40", "ifVlan": "50", "ifTrunk": None}, + {"port_id": 114326, "ifName": "Gi3/0/48", "ifVlan": "1", "ifTrunk": "dot1Q"}, + {"port_id": 114327, "ifName": "Gi3/1/1", "ifVlan": "1", "ifTrunk": None}, + {"port_id": 114145, "ifName": "Gi1/0/1", "ifVlan": "", "ifTrunk": None}, # No VLAN + ], +} + +# Sample port with vlans detail response (for trunk port) +MOCK_PORT_VLAN_DETAILS_TRUNK = { + "status": "ok", + "port": [ + { + "port_id": 227011, + "ifName": "Te1/1/1", + "ifVlan": "90", + "ifTrunk": "dot1Q", + "vlans": [ + {"vlan": 90, "untagged": 1, "state": "unknown", "port_vlan_id": 195164}, + {"vlan": 50, "untagged": 0, "state": "forwarding", "port_vlan_id": 2165422}, + ], + } + ], +} + +# Sample port with vlans detail response (for access port) +MOCK_PORT_VLAN_DETAILS_ACCESS = { + "status": "ok", + "port": [ + { + "port_id": 729403, + "ifName": "Gi0/2", + "ifVlan": "50", + "ifTrunk": None, + "vlans": [ + {"vlan": 50, "untagged": 1, "state": "forwarding", "port_vlan_id": 3234550}, + ], + } + ], +} + + +def create_mock_device(): + """Create a mock NetBox device.""" + device = MagicMock() + device.pk = 123 + device.name = "test-switch" + device._meta.model_name = "device" + device.site = MagicMock() + device.site.pk = 1 + device.site.name = "Test Site" + return device + + +def create_mock_interface(name, mode=None, untagged_vlan=None, tagged_vlans=None): + """Create a mock NetBox interface.""" + interface = MagicMock() + interface.pk = hash(name) + interface.name = name + interface.mode = mode + interface.untagged_vlan = untagged_vlan + interface.tagged_vlans = MagicMock() + interface.tagged_vlans.all.return_value = tagged_vlans or [] + return interface + + +def create_mock_vlan(vid, name, group=None): + """Create a mock NetBox VLAN.""" + vlan = MagicMock() + vlan.pk = vid * 100 + vlan.vid = vid + vlan.name = name + vlan.group = group + return vlan + + +# ============================================ +# API METHOD TESTS +# ============================================ + + +class TestVLANAPIClient: + """Tests for LibreNMS VLAN API methods.""" + + @patch("requests.get") + def test_get_device_vlans_success(self, mock_get, mock_librenms_config): + """Test successful VLAN fetch from /resources/vlans endpoint.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = MOCK_DEVICE_VLANS + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + success, data = api.get_device_vlans(123) + + assert success is True + assert len(data) == 3 + assert data[1]["vlan_vlan"] == 50 + assert data[1]["vlan_name"] == "ORG_DATA" + # Verify vlan_id is present from /resources/vlans endpoint + assert data[1]["vlan_id"] == 102 + + @patch("requests.get") + def test_get_device_vlans_filters_by_device_id(self, mock_get, mock_librenms_config): + """Test that VLANs are filtered by device_id.""" + # Response includes VLANs from multiple devices + mock_response_data = { + "status": "ok", + "vlans": [ + {"vlan_id": 101, "device_id": 123, "vlan_vlan": 1, "vlan_name": "default"}, + {"vlan_id": 201, "device_id": 456, "vlan_vlan": 1, "vlan_name": "default"}, # Different device + {"vlan_id": 102, "device_id": 123, "vlan_vlan": 50, "vlan_name": "DATA"}, + ], + } + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = mock_response_data + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + success, data = api.get_device_vlans(123) + + assert success is True + assert len(data) == 2 # Only device 123's VLANs + assert all(str(v["device_id"]) == "123" for v in data) + + @patch("requests.get") + def test_get_device_vlans_error(self, mock_get, mock_librenms_config): + """Test VLAN fetch with error.""" + from requests.exceptions import HTTPError + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = HTTPError(response=mock_response) + mock_get.return_value = mock_response + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + success, data = api.get_device_vlans(999) + + assert success is False + assert "not found" in data.lower() + + @patch("requests.get") + def test_get_port_vlan_details_trunk(self, mock_get, mock_librenms_config): + """Test fetching trunk port VLAN details.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = MOCK_PORT_VLAN_DETAILS_TRUNK + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + success, data = api.get_port_vlan_details(227011) + + assert success is True + assert data["ifTrunk"] == "dot1Q" + assert len(data["vlans"]) == 2 + + @patch("requests.get") + def test_get_port_vlan_details_not_found(self, mock_get, mock_librenms_config): + """Test fetching port details when port not found.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"status": "ok", "port": []} + + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + success, data = api.get_port_vlan_details(999999) + + assert success is False + assert "not found" in data.lower() + + +# ============================================ +# MODE DETECTION TESTS +# ============================================ + + +class TestVLANModeDetection: + """Tests for 802.1Q mode detection logic.""" + + def test_parse_port_vlan_data_access_port(self, mock_librenms_config): + """Access port: ifVlan set, ifTrunk null.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = {"port_id": 1, "ifName": "Gi1/0/1", "ifVlan": "50", "ifTrunk": None} + result = api.parse_port_vlan_data(port_data) + + assert result["mode"] == "access" + assert result["untagged_vlan"] == 50 + assert result["tagged_vlans"] == [] + + def test_parse_port_vlan_data_trunk_port(self, mock_librenms_config): + """Trunk port: ifTrunk = dot1Q.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = { + "port_id": 2, + "ifName": "Te1/1/1", + "ifVlan": "90", + "ifTrunk": "dot1Q", + "vlans": [ + {"vlan": 90, "untagged": 1}, + {"vlan": 50, "untagged": 0}, + {"vlan": 60, "untagged": 0}, + ], + } + result = api.parse_port_vlan_data(port_data) + + assert result["mode"] == "tagged" + assert result["untagged_vlan"] == 90 + assert result["tagged_vlans"] == [50, 60] + + def test_parse_port_vlan_data_no_vlan(self, mock_librenms_config): + """No VLAN: ifVlan empty.""" + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + + port_data = {"port_id": 3, "ifName": "Gi1/0/48", "ifVlan": "", "ifTrunk": None} + result = api.parse_port_vlan_data(port_data) + + assert result["mode"] is None + assert result["untagged_vlan"] is None + assert result["tagged_vlans"] == [] + + +# ============================================ +# VLAN COMPARISON TESTS +# ============================================ + + +class TestVLANComparison: + """Tests for VLAN comparison logic.""" + + def test_compare_vlans_exists_in_netbox(self): + """Test VLAN exists in NetBox VLAN group.""" + netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")} + librenms_vlan = {"vlan_vlan": 50, "vlan_name": "ORG_DATA"} + + exists = librenms_vlan["vlan_vlan"] in netbox_vlans + assert exists is True + + def test_compare_vlans_missing_from_netbox(self): + """Test VLAN missing from NetBox.""" + netbox_vlans = {50: create_mock_vlan(50, "ORG_DATA")} + librenms_vlan = {"vlan_vlan": 60, "vlan_name": "ORG_VOICE"} + + exists = librenms_vlan["vlan_vlan"] in netbox_vlans + assert exists is False + + def test_compare_vlans_name_matches(self): + """Test VLAN name comparison when matching.""" + netbox_vlan = create_mock_vlan(50, "ORG_DATA") + librenms_name = "ORG_DATA" + + name_matches = netbox_vlan.name == librenms_name + assert name_matches is True + + def test_compare_vlans_name_differs(self): + """Test VLAN name comparison when different.""" + netbox_vlan = create_mock_vlan(50, "DATA_VLAN") + librenms_name = "ORG_DATA" + + name_matches = netbox_vlan.name == librenms_name + assert name_matches is False + + +# ============================================ +# PORT VLAN PARSING TESTS +# ============================================ + + +class TestPortVLANParsing: + """Tests for parsing port VLAN data.""" + + def test_parse_trunk_port_vlans(self): + """Parse trunk port into untagged and tagged lists.""" + vlans_data = MOCK_PORT_VLAN_DETAILS_TRUNK["port"][0]["vlans"] + + untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] + tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] + + assert untagged == [90] + assert tagged == [50] + + def test_parse_access_port_vlans(self): + """Parse access port - single untagged VLAN.""" + vlans_data = MOCK_PORT_VLAN_DETAILS_ACCESS["port"][0]["vlans"] + + untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] + tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] + + assert untagged == [50] + assert tagged == [] + + def test_parse_port_with_multiple_tagged(self): + """Parse trunk port with multiple tagged VLANs.""" + vlans_data = [ + {"vlan": 1, "untagged": 1}, + {"vlan": 10, "untagged": 0}, + {"vlan": 20, "untagged": 0}, + {"vlan": 30, "untagged": 0}, + ] + + untagged = [v["vlan"] for v in vlans_data if v["untagged"] == 1] + tagged = [v["vlan"] for v in vlans_data if v["untagged"] == 0] + + assert untagged == [1] + assert len(tagged) == 3 + assert set(tagged) == {10, 20, 30} + + +# ============================================ +# SYNC ACTION TESTS +# ============================================ + + +class TestSyncVLANActions: + """Tests for VLAN sync action logic.""" + + def test_mode_mapping_access(self): + """Test mapping LibreNMS access mode to NetBox.""" + librenms_mode = "access" + expected_netbox_mode = "access" + + mode_map = {"access": "access", "tagged": "tagged"} + result = mode_map.get(librenms_mode) + + assert result == expected_netbox_mode + + def test_mode_mapping_tagged(self): + """Test mapping LibreNMS tagged mode to NetBox.""" + librenms_mode = "tagged" + expected_netbox_mode = "tagged" + + mode_map = {"access": "access", "tagged": "tagged"} + result = mode_map.get(librenms_mode) + + assert result == expected_netbox_mode + + def test_vlan_state_mapping_active(self): + """Test mapping active VLAN state.""" + vlan_state = 1 + + status = "active" if vlan_state == 1 else "reserved" + assert status == "active" + + def test_vlan_state_mapping_inactive(self): + """Test mapping inactive VLAN state.""" + vlan_state = 0 + + status = "active" if vlan_state == 1 else "reserved" + assert status == "reserved" + + +# ============================================ +# VLAN SYNC CSS CLASS UTILITY +# ============================================ + + +class TestGetVlanSyncCssClass: + """Tests for the shared get_vlan_sync_css_class utility.""" + + def test_not_in_netbox(self): + """VLAN not in NetBox should return text-danger.""" + from netbox_librenms_plugin.utils import get_vlan_sync_css_class + + assert get_vlan_sync_css_class(exists_in_netbox=False) == "text-danger" + + def test_not_in_netbox_name_match_irrelevant(self): + """Name match flag should be irrelevant when VLAN doesn't exist.""" + from netbox_librenms_plugin.utils import get_vlan_sync_css_class + + assert get_vlan_sync_css_class(exists_in_netbox=False, name_matches=True) == "text-danger" + + def test_exists_name_matches(self): + """VLAN exists with matching name should return text-success.""" + from netbox_librenms_plugin.utils import get_vlan_sync_css_class + + assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=True) == "text-success" + + def test_exists_name_mismatch(self): + """VLAN exists but name differs should return text-warning.""" + from netbox_librenms_plugin.utils import get_vlan_sync_css_class + + assert get_vlan_sync_css_class(exists_in_netbox=True, name_matches=False) == "text-warning" + + def test_default_name_matches_is_true(self): + """Default name_matches should be True (success when exists).""" + from netbox_librenms_plugin.utils import get_vlan_sync_css_class + + assert get_vlan_sync_css_class(exists_in_netbox=True) == "text-success" diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 5660b3eb88..af12187c6f 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -18,6 +18,7 @@ DeviceStatusListView, DeviceValidationDetailsView, DeviceVCDetailsView, + DeviceVLANTableView, InterfaceTypeMappingBulkDeleteView, InterfaceTypeMappingBulkImportView, InterfaceTypeMappingChangeLogView, @@ -28,13 +29,18 @@ InterfaceTypeMappingView, LibreNMSImportView, LibreNMSSettingsView, + SaveUserPrefView, SingleCableVerifyView, SingleInterfaceVerifyView, SingleIPAddressVerifyView, + SaveVlanGroupOverridesView, + SingleVlanGroupVerifyView, + VerifyVlanSyncGroupView, SyncCablesView, SyncInterfacesView, SyncIPAddressesView, SyncSiteLocationView, + SyncVLANsView, TestLibreNMSConnectionView, UpdateDeviceLocationView, UpdateDevicePlatformView, @@ -85,6 +91,24 @@ SingleIPAddressVerifyView.as_view(), name="verify_ipaddress", ), + # Path for VLAN group verify javascript call (interface VLAN coloring) + path( + "verify-vlan-group/", + SingleVlanGroupVerifyView.as_view(), + name="verify_vlan_group", + ), + # Verify VLAN existence in a group (VLAN sync tab coloring) + path( + "verify-vlan-sync-group/", + VerifyVlanSyncGroupView.as_view(), + name="verify_vlan_sync_group", + ), + # Save VLAN group overrides to cache ("apply to all" persistence) + path( + "save-vlan-group-overrides/", + SaveVlanGroupOverridesView.as_view(), + name="save_vlan_group_overrides", + ), # Virtual machine sync URLs path( "virtual-machines//interface-sync/", @@ -125,6 +149,17 @@ SyncIPAddressesView.as_view(), name="sync_device_ip_addresses", ), + # VLAN sync URLs + path( + "devices//vlan-sync/", + DeviceVLANTableView.as_view(), + name="device_vlan_sync", + ), + path( + "//sync-vlans/", + SyncVLANsView.as_view(), + name="sync_selected_vlans", + ), # Add Device to LibreNMS URLs path( "add-device//", @@ -224,6 +259,11 @@ DeviceRackUpdateView.as_view(), name="device_rack_update", ), + path( + "save-user-pref/", + SaveUserPrefView.as_view(), + name="save_user_pref", + ), path( "vm-status/", VMStatusListView.as_view(), diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index cb421f19fa..4a5bf113a4 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -142,10 +142,29 @@ def get_table_paginate_count(request: HttpRequest, table_prefix: str) -> int: return netbox_get_paginate_count(request) +def get_user_pref(request, path, default=None): + """Get a user preference value via request.user.config.""" + if hasattr(request, "user") and hasattr(request.user, "config"): + return request.user.config.get(path, default) + return default + + +def save_user_pref(request, path, value): + """Save a user preference value via request.user.config.""" + if hasattr(request, "user") and hasattr(request.user, "config"): + try: + request.user.config.set(path, value, commit=True) + except (TypeError, ValueError): + pass + + def get_interface_name_field(request: Optional[HttpRequest] = None) -> str: """ Get interface name field with request override support. + Checks in order: GET/POST params, user preference, plugin config default. + When a param is explicitly provided, persists it to user preferences. + Args: request: Optional HTTP request object that may contain override @@ -153,10 +172,18 @@ def get_interface_name_field(request: Optional[HttpRequest] = None) -> str: str: Interface name field to use """ if request: - if request.GET.get("interface_name_field"): - return request.GET.get("interface_name_field") - if request.POST.get("interface_name_field"): - return request.POST.get("interface_name_field") + # Explicit override from request params + param_val = request.GET.get("interface_name_field") or request.POST.get("interface_name_field") + if param_val: + existing = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field") + if param_val != existing: + save_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field", param_val) + return param_val + + # Check user preference + pref_val = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field") + if pref_val: + return pref_val # Fall back to plugin config return get_plugin_config("netbox_librenms_plugin", "interface_name_field") @@ -278,3 +305,145 @@ def find_matching_platform(librenms_os: str) -> dict: return {"found": True, "platform": platform, "match_type": "exact"} return {"found": False, "platform": None, "match_type": None} + + +def get_vlan_sync_css_class(exists_in_netbox: bool, name_matches: bool = True) -> str: + """ + Determine CSS class for a VLAN row on the VLAN sync tab. + + Used by both the server-side table renderer (LibreNMSVLANTable) + and the client-facing verify endpoint (VerifyVlanSyncGroupView) + to keep color logic consistent. + + Args: + exists_in_netbox: Whether the VLAN exists in NetBox (in the selected group or globally). + name_matches: Whether the VLAN name in NetBox matches the LibreNMS name. + + Returns: + CSS class string: 'text-success', 'text-warning', or 'text-danger'. + """ + if not exists_in_netbox: + return "text-danger" + if name_matches: + return "text-success" + return "text-warning" + + +# ============================================ +# Interface VLAN CSS helpers +# ============================================ +# Shared by LibreNMSInterfaceTable (tables/interfaces.py) and +# SingleVlanGroupVerifyView (views/object_sync/devices.py). + + +def get_untagged_vlan_css_class(librenms_vid, netbox_vid, exists_in_netbox, missing_vlans, group_matches=True): + """ + Get CSS class for an untagged VLAN comparison. + + Color logic: + - Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync) + - Red (text-danger): Interface missing from NetBox, or no untagged VLAN in NetBox + - Orange (text-warning): Different untagged VLAN assigned, or same VID but different group + - Green (text-success): Same untagged VLAN assigned in same group (match) + + Args: + librenms_vid: VLAN ID from LibreNMS. + netbox_vid: VLAN ID currently assigned in NetBox (int or None). + exists_in_netbox: Whether the interface exists in NetBox. + missing_vlans: List of VIDs not found in any NetBox VLAN group. + group_matches: Whether the selected VLAN group matches the NetBox VLAN's group. + Only meaningful when VIDs match; defaults to True. + + Returns: + CSS class string: text-danger, text-warning, or text-success. + """ + if not exists_in_netbox: + return "text-danger" + if librenms_vid in missing_vlans: + return "text-danger" + if librenms_vid == netbox_vid: + if not group_matches: + return "text-warning" + return "text-success" + if netbox_vid is None: + return "text-danger" + return "text-warning" + + +def get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches=True): + """ + Get CSS class for a tagged VLAN comparison. + + Color logic: + - Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync) + - Red (text-danger): Interface missing from NetBox, or VLAN not tagged on this interface + - Orange (text-warning): Same VID tagged but in different VLAN group + - Green (text-success): VLAN is tagged on this interface in same group + + Args: + vid: VLAN ID to check. + netbox_tagged_vids: Set of VIDs currently tagged on the NetBox interface. + exists_in_netbox: Whether the interface exists in NetBox. + missing_vlans: List of VIDs not found in any NetBox VLAN group. + group_matches: Whether the selected VLAN group matches the NetBox VLAN's group. + Only meaningful when VIDs match; defaults to True. + + Returns: + CSS class string: text-danger, text-warning, or text-success. + """ + if not exists_in_netbox: + return "text-danger" + if vid in missing_vlans: + return "text-danger" + if vid in netbox_tagged_vids: + if not group_matches: + return "text-warning" + return "text-success" + return "text-danger" + + +def get_missing_vlan_warning(vid, missing_vlans): + """Return warning icon HTML if VLAN is not found in any NetBox VLAN group.""" + if vid in missing_vlans: + return ( + ' ' + ) + return "" + + +def check_vlan_group_matches( + vlan_type, + vid, + selected_group_id, + netbox_untagged_group_id, + netbox_tagged_group_ids, + netbox_untagged_vid, + netbox_tagged_vids, +): + """ + Check whether the selected VLAN group matches the NetBox VLAN's group. + + Only relevant when VIDs match β€” if VIDs differ, the CSS is already + warning/danger regardless of group. + + Args: + vlan_type: "U" or "T". + vid: VLAN ID. + selected_group_id: Group ID (int or None) the user selected. + netbox_untagged_group_id: group_id of netbox untagged VLAN (int or None). + netbox_tagged_group_ids: {vid: group_id} of netbox tagged VLANs. + netbox_untagged_vid: VID of netbox untagged VLAN (int or None). + netbox_tagged_vids: set of VIDs tagged in netbox. + + Returns: + bool: True if groups match (or comparison not applicable). + """ + if vlan_type == "U": + if netbox_untagged_vid == vid: + return netbox_untagged_group_id == selected_group_id + else: + if vid in netbox_tagged_vids: + netbox_gid = netbox_tagged_group_ids.get(vid) + return netbox_gid == selected_group_id + return True diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index a81c5459ac..d2b3bbdd43 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -6,6 +6,7 @@ from .base.interfaces_view import BaseInterfaceTableView from .base.ip_addresses_view import BaseIPAddressTableView, SingleIPAddressVerifyView from .base.librenms_sync_view import BaseLibreNMSSyncView +from .base.vlan_table_view import BaseVLANTableView from .imports import ( BulkImportConfirmView, BulkImportDevicesView, @@ -15,6 +16,7 @@ DeviceValidationDetailsView, DeviceVCDetailsView, LibreNMSImportView, + SaveUserPrefView, ) from .mapping_views import ( InterfaceTypeMappingBulkDeleteView, @@ -31,7 +33,11 @@ DeviceInterfaceTableView, DeviceIPAddressTableView, DeviceLibreNMSSyncView, + DeviceVLANTableView, + SaveVlanGroupOverridesView, SingleInterfaceVerifyView, + SingleVlanGroupVerifyView, + VerifyVlanSyncGroupView, VMInterfaceTableView, VMIPAddressTableView, VMLibreNMSSyncView, @@ -50,3 +56,4 @@ from .sync.interfaces import DeleteNetBoxInterfacesView, SyncInterfacesView from .sync.ip_addresses import SyncIPAddressesView from .sync.locations import SyncSiteLocationView +from .sync.vlans import SyncVLANsView diff --git a/netbox_librenms_plugin/views/base/__init__.py b/netbox_librenms_plugin/views/base/__init__.py index e69de29bb2..b354745320 100644 --- a/netbox_librenms_plugin/views/base/__init__.py +++ b/netbox_librenms_plugin/views/base/__init__.py @@ -0,0 +1,13 @@ +from .cables_view import BaseCableTableView +from .interfaces_view import BaseInterfaceTableView +from .ip_addresses_view import BaseIPAddressTableView +from .librenms_sync_view import BaseLibreNMSSyncView +from .vlan_table_view import BaseVLANTableView + +__all__ = [ + "BaseCableTableView", + "BaseInterfaceTableView", + "BaseIPAddressTableView", + "BaseLibreNMSSyncView", + "BaseVLANTableView", +] diff --git a/netbox_librenms_plugin/views/base/cables_view.py b/netbox_librenms_plugin/views/base/cables_view.py index 0322b6facb..c390cdd539 100644 --- a/netbox_librenms_plugin/views/base/cables_view.py +++ b/netbox_librenms_plugin/views/base/cables_view.py @@ -14,10 +14,10 @@ get_interface_name_field, get_virtual_chassis_member, ) -from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin -class BaseCableTableView(LibreNMSAPIMixin, CacheMixin, View): +class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View): """ Base view for synchronizing cable information from LibreNMS. """ diff --git a/netbox_librenms_plugin/views/base/interfaces_view.py b/netbox_librenms_plugin/views/base/interfaces_view.py index 5cca0bfe43..80e413a8f3 100644 --- a/netbox_librenms_plugin/views/base/interfaces_view.py +++ b/netbox_librenms_plugin/views/base/interfaces_view.py @@ -8,12 +8,18 @@ get_interface_name_field, get_virtual_chassis_member, ) -from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import ( + CacheMixin, + LibreNMSAPIMixin, + LibreNMSPermissionMixin, + VlanAssignmentMixin, +) -class BaseInterfaceTableView(LibreNMSAPIMixin, CacheMixin, View): +class BaseInterfaceTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View): """ Base view for fetching interface data from LibreNMS and generating table data. + Includes VLAN enrichment for interface VLAN sync functionality. """ model = None # To be defined in subclasses @@ -50,10 +56,16 @@ def get_select_related_field(self, obj): return "virtual_machine" return "device" - def get_table(self, data, obj, interface_name_field): + def get_table(self, data, obj, interface_name_field, vlan_groups=None): """ Returns the table class to use for rendering interface data. Can be overridden by subclasses to use different tables. + + Args: + data: List of port data dicts + obj: Device or VirtualMachine object + interface_name_field: Field to use for interface name ('ifName' or 'ifDescr') + vlan_groups: List of VLANGroup objects for VLAN group dropdowns """ raise NotImplementedError("Subclasses must implement get_table()") @@ -76,6 +88,11 @@ def post(self, request, pk): messages.error(request, librenms_data) return redirect(self.get_redirect_url(obj)) + # Enrich ports with VLAN data for trunk ports + ports = librenms_data.get("ports", []) + enriched_ports = self._enrich_ports_with_vlan_data(ports, interface_name_field) + librenms_data["ports"] = enriched_ports + # Store data in cache cache.set( self.get_cache_key(obj, "ports"), @@ -97,6 +114,30 @@ def post(self, request, pk): return render(request, self.partial_template_name, context) + def _enrich_ports_with_vlan_data(self, ports, interface_name_field): + """ + Enrich port data with VLAN information from LibreNMS. + + With LibreNMS 24.2.0+, the get_ports() call with with_vlans=True returns + detailed VLAN associations (tagged/untagged) for all ports. The + parse_port_vlan_data() method handles both the new vlans array format + and falls back to ifVlan for older LibreNMS versions. + + Args: + ports: List of port dicts from get_ports(with_vlans=True) + interface_name_field: Field to use for interface name + + Returns: + List of enriched port dicts with VLAN data + """ + enriched = [] + for port in ports: + # Parse VLAN data - handles both vlans array (new) and ifVlan fallback (old) + parsed = self.librenms_api.parse_port_vlan_data(port, interface_name_field) + port.update(parsed) + enriched.append(port) + return enriched + def get_context_data(self, request, obj, interface_name_field): """Get the context data for the interface sync view.""" ports_data = [] @@ -107,7 +148,14 @@ def get_context_data(self, request, obj, interface_name_field): interface_name_field = get_interface_name_field(request) cached_data = cache.get(self.get_cache_key(obj, "ports")) - last_fetched = cache.get(self.get_last_fetched_key(obj), "ports") + last_fetched = cache.get(self.get_last_fetched_key(obj, "ports")) + + # Get VLAN groups for dropdown + vlan_groups = self.get_vlan_groups_for_device(obj) + lookup_maps = self._build_vlan_lookup_maps(vlan_groups) + + # Load any user VLAN group overrides from cache (set by "apply to all") + vlan_group_overrides = cache.get(self.get_vlan_overrides_key(obj)) or {} if cached_data: ports_data = cached_data.get("ports", []) @@ -129,7 +177,7 @@ def get_context_data(self, request, obj, interface_name_field): for port in ports_data: port["enabled"] = ( True - if port["ifAdminStatus"] is None + if port.get("ifAdminStatus") is None else ( port["ifAdminStatus"].lower() == "up" if isinstance(port["ifAdminStatus"], str) @@ -138,19 +186,25 @@ def get_context_data(self, request, obj, interface_name_field): ) if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: - chassis_member = get_virtual_chassis_member(obj, port[interface_name_field]) + chassis_member = get_virtual_chassis_member(obj, port.get(interface_name_field)) device_interfaces = interfaces_by_device.get(chassis_member.id, {}) else: device_interfaces = interfaces_by_device[obj.id] - netbox_interface = device_interfaces.get(port[interface_name_field]) + netbox_interface = device_interfaces.get(port.get(interface_name_field)) port["exists_in_netbox"] = bool(netbox_interface) port["netbox_interface"] = netbox_interface - if port["ifAlias"] in (port["ifDescr"], port["ifName"]): + if port.get("ifAlias") in (port.get("ifDescr"), port.get("ifName")): port["ifAlias"] = "" - table = self.get_table(ports_data, obj, interface_name_field) + # Add VLAN group auto-selection data to port, applying any user overrides + self._add_vlan_group_selection(port, lookup_maps, obj, vlan_group_overrides) + + # Add missing VLANs info for warning display + self._add_missing_vlans_info(port, lookup_maps) + + table = self.get_table(ports_data, obj, interface_name_field, vlan_groups=vlan_groups) table.configure(request) # Identify NetBox-only interfaces (interfaces in NetBox but not in LibreNMS) @@ -196,9 +250,118 @@ def get_context_data(self, request, obj, interface_name_field): return { "object": obj, "table": table, + "vlan_groups": vlan_groups, "last_fetched": last_fetched, "cache_expiry": cache_expiry, "virtual_chassis_members": virtual_chassis_members, "interface_name_field": interface_name_field, "netbox_only_interfaces": netbox_only_interfaces, } + + def _add_vlan_group_selection(self, port, lookup_maps, device, vlan_group_overrides=None): + """ + Add per-VLAN group auto-selection data to port record. + + Sets: + - vlan_group_map: {vid: {"group_id": str, "group_name": str, "is_ambiguous": bool}} + Maps each VID to its auto-selected VLAN group based on scope hierarchy. + If vlan_group_overrides contains a user selection for a VID, that takes + precedence over auto-selection. + """ + vid_to_groups = lookup_maps.get("vid_to_groups", {}) + untagged_vid = port.get("untagged_vlan") + tagged_vids = port.get("tagged_vlans", []) + + all_vids = [] + if untagged_vid: + all_vids.append(untagged_vid) + all_vids.extend(tagged_vids) + + vlan_group_map = {} + for vid in all_vids: + groups = vid_to_groups.get(vid, []) + if len(groups) == 1: + vlan_group_map[vid] = { + "group_id": str(groups[0].pk), + "group_name": groups[0].name, + "is_ambiguous": False, + } + elif len(groups) > 1: + most_specific = self._select_most_specific_group(groups, device) + if most_specific: + vlan_group_map[vid] = { + "group_id": str(most_specific.pk), + "group_name": most_specific.name, + "is_ambiguous": False, + } + else: + vlan_group_map[vid] = { + "group_id": "", + "group_name": "Ambiguous", + "is_ambiguous": True, + } + else: + vlan_group_map[vid] = { + "group_id": "", + "group_name": "Global", + "is_ambiguous": False, + } + + # Apply user overrides from "apply to all" selections (persisted in cache) + if vlan_group_overrides: + from ipam.models import VLANGroup + + # Batch-fetch all referenced override group IDs to avoid N+1 queries + override_group_ids = { + vlan_group_overrides[str(vid)] + for vid in all_vids + if str(vid) in vlan_group_overrides and vlan_group_overrides[str(vid)] + } + override_groups_by_id = {} + if override_group_ids: + override_groups_by_id = VLANGroup.objects.in_bulk(list(override_group_ids)) + + for vid in all_vids: + vid_str = str(vid) + if vid_str in vlan_group_overrides: + override_group_id = vlan_group_overrides[vid_str] + if override_group_id: + group = override_groups_by_id.get(int(override_group_id)) + if group: + vlan_group_map[vid] = { + "group_id": str(group.pk), + "group_name": group.name, + "is_ambiguous": False, + } + # else: Override references deleted group; keep auto-selection + else: + # User explicitly chose "No Group (Global)" + vlan_group_map[vid] = { + "group_id": "", + "group_name": "Global", + "is_ambiguous": False, + } + + port["vlan_group_map"] = vlan_group_map + + def _add_missing_vlans_info(self, port, lookup_maps): + """ + Add missing VLANs info to port record for warning display. + + Sets: + - missing_vlans: List of VIDs not found in any NetBox VLAN group + """ + vid_to_vlans = lookup_maps.get("vid_to_vlans", {}) + missing_vlans = [] + + untagged_vid = port.get("untagged_vlan") + tagged_vids = port.get("tagged_vlans", []) + + if untagged_vid and untagged_vid not in vid_to_vlans: + missing_vlans.append(untagged_vid) + + for vid in tagged_vids: + if vid not in vid_to_vlans: + missing_vlans.append(vid) + + port["missing_vlans"] = missing_vlans diff --git a/netbox_librenms_plugin/views/base/ip_addresses_view.py b/netbox_librenms_plugin/views/base/ip_addresses_view.py index 58b23d8fa1..22f4b49742 100644 --- a/netbox_librenms_plugin/views/base/ip_addresses_view.py +++ b/netbox_librenms_plugin/views/base/ip_addresses_view.py @@ -12,10 +12,10 @@ from netbox_librenms_plugin.tables.ipaddresses import IPAddressTable from netbox_librenms_plugin.utils import get_interface_name_field -from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin -class BaseIPAddressTableView(LibreNMSAPIMixin, CacheMixin, View): +class BaseIPAddressTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View): """ Base view for synchronizing IP address information from LibreNMS. """ @@ -301,7 +301,7 @@ def post(self, request, pk): ) -class SingleIPAddressVerifyView(CacheMixin, View): +class SingleIPAddressVerifyView(LibreNMSPermissionMixin, CacheMixin, View): """ View for verifying single IP address data with different VRF. """ diff --git a/netbox_librenms_plugin/views/base/librenms_sync_view.py b/netbox_librenms_plugin/views/base/librenms_sync_view.py index 1049d6fc18..1346c3cb13 100644 --- a/netbox_librenms_plugin/views/base/librenms_sync_view.py +++ b/netbox_librenms_plugin/views/base/librenms_sync_view.py @@ -3,16 +3,16 @@ from django.shortcuts import get_object_or_404, render from netbox.views import generic -from netbox_librenms_plugin.forms import AddToLIbreSNMPV2, AddToLIbreSNMPV3 +from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3 from netbox_librenms_plugin.utils import ( get_interface_name_field, get_librenms_sync_device, match_librenms_hardware_to_device_type, ) -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin -class BaseLibreNMSSyncView(LibreNMSAPIMixin, generic.ObjectListView): +class BaseLibreNMSSyncView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView): """ Base view for LibreNMS sync information. """ @@ -85,6 +85,7 @@ def get_context_data(self, request, obj): interface_context = self.get_interface_context(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) interface_name_field = get_interface_name_field(request) @@ -101,7 +102,8 @@ def get_context_data(self, request, obj): "interface_sync": interface_context, "cable_sync": cable_context, "ip_sync": ip_context, - "v2form": AddToLIbreSNMPV2(prefix="v2"), + "vlan_sync": vlan_context, + "v1v2form": AddToLIbreSNMPV1V2(prefix="v1v2"), "v3form": AddToLIbreSNMPV3(prefix="v3"), "librenms_device_id": self.librenms_id, "found_in_librenms": librenms_info.get("found_in_librenms"), @@ -137,11 +139,11 @@ def get_librenms_device_info(self, obj): success, device_info = self.librenms_api.get_device_info(self.librenms_id) if success and device_info: # Get NetBox device details - netbox_ip = str(obj.primary_ip.address.ip) if obj.primary_ip else None - netbox_hostname = obj.name + netbox_ip = str(obj.primary_ip.address.ip).lower() if obj.primary_ip else None + netbox_name = obj.name # Get LibreNMS device details - librenms_hostname = device_info.get("sysName") + librenms_sysname = device_info.get("sysName") librenms_ip = device_info.get("ip") # Extract new fields @@ -165,7 +167,8 @@ def get_librenms_device_info(self, obj): "librenms_device_features": features, "librenms_device_location": device_info.get("location", "-"), "librenms_device_ip": librenms_ip, - "sysName": librenms_hostname, + "sysName": librenms_sysname, + "librenms_device_hostname": device_info.get("hostname", "-"), "librenms_device_hardware_match": hardware_match, } ) @@ -175,40 +178,62 @@ def get_librenms_device_info(self, obj): vc_serials = self._get_vc_inventory_serials(obj) librenms_device_details["vc_inventory_serials"] = vc_serials - # Get just the hostname part from LibreNMS FQDN if present - librenms_host = librenms_hostname.split(".")[0].lower() if librenms_hostname else None - netbox_host = netbox_hostname.split(".")[0].lower() if netbox_hostname else None - - # Check for matching IP or hostname - # If IP matches, we have a match - if netbox_ip == librenms_ip: - found_in_librenms = True - # Check hostname match with normalization for VC suffixes - elif netbox_host and librenms_host: - # Normalize NetBox hostname by removing VC member suffixes like ' (1)', ' (2)', etc. - netbox_host_normalized = re.sub(r"\s*\(\d+\)$", "", netbox_host) - - if netbox_host_normalized == librenms_host: - found_in_librenms = True - # For VC members with explicit librenms_id, validate hostname similarity - elif hasattr(obj, "virtual_chassis") and obj.virtual_chassis and obj.cf.get("librenms_id"): - # Extract base hostname (before any VC numbering like -1, -2, etc.) - # This handles cases where VC members in NetBox (e.g., "switch-1 (1)") - # point to the primary device in LibreNMS (e.g., "switch-1") - netbox_base = re.sub(r"[-_]?\d+$", "", netbox_host_normalized) - librenms_base = re.sub(r"[-_]?\d+$", "", librenms_host) - - if netbox_base and librenms_base and netbox_base == librenms_base: - # Base hostnames match (e.g., "switch" matches "switch") - found_in_librenms = True - else: - # Hostnames don't match even after normalization - mismatched_device = True - else: - mismatched_device = True + # Device was retrieved successfully via librenms_id β€” trust the ID + found_in_librenms = True + + # Normalise the NetBox name once for comparisons + netbox_name_norm = netbox_name.lower() if netbox_name else None + if netbox_name_norm: + # Strip VC member suffix like " (1)" before comparing + netbox_name_norm = re.sub(r"\s*\(\d+\)$", "", netbox_name_norm) + + # Also strip the VC member naming pattern from settings + # (e.g. "-M2", " (2)", "-SW3") to recover the base device name + netbox_name_vc_stripped = None + if netbox_name_norm: + netbox_name_vc_stripped = self._strip_vc_pattern(netbox_name_norm) + + # Collect all NetBox identity values to compare against + netbox_dns_name = ( + obj.primary_ip.dns_name.lower() if obj.primary_ip and obj.primary_ip.dns_name else None + ) + netbox_identities = { + v + for v in [ + netbox_name_norm, + netbox_ip, + netbox_dns_name, + netbox_name_vc_stripped, + ] + if v + } + + # Collect all LibreNMS identity values, including + # domain-stripped short names (e.g. "sw01.example.net" β†’ "sw01") + librenms_hostname = device_info.get("hostname") + librenms_values = [] + for val in [librenms_sysname, librenms_hostname, librenms_ip]: + if val: + lower_val = val.lower() + librenms_values.append(lower_val) + # Add short name (strip domain) if it looks like an FQDN + short = lower_val.split(".")[0] + if short != lower_val: + librenms_values.append(short) + librenms_identities = set(librenms_values) + + # A device is considered matched when ANY NetBox identity + # appears in the LibreNMS identities. This covers: + # - NetBox name == sysName or hostname + # - NetBox primary IP == LibreNMS hostname (added by IP) + # - NetBox DNS name == sysName or hostname (FQDN match) + if netbox_identities & librenms_identities: + mismatched_device = False else: mismatched_device = True + librenms_device_details["netbox_dns_name"] = netbox_dns_name or "-" + return { "found_in_librenms": found_in_librenms, "librenms_device_details": librenms_device_details, @@ -236,6 +261,48 @@ def get_ip_context(self, request, obj): """ return None + def get_vlan_context(self, request, obj): + """ + Get the context data for VLAN sync. + Subclasses should override this method. + """ + return None + + @staticmethod + def _strip_vc_pattern(name): + """Strip the VC member naming suffix from a device name. + + Uses the vc_member_name_pattern from LibreNMSSettings to build a + regex that removes the suffix. For example, with the default + pattern ``-M{position}`` and name ``switch01-m2``, this returns + ``switch01``. + + Returns the stripped name, or None if it equals the original + (i.e. no suffix was found). + """ + try: + from netbox_librenms_plugin.models import LibreNMSSettings + + settings = LibreNMSSettings.objects.first() + pattern = ( + settings.vc_member_name_pattern + if settings and isinstance(settings.vc_member_name_pattern, str) + else "-M{position}" + ) + if not isinstance(pattern, str): + pattern = "-M{position}" + + # Turn the pattern into a regex by replacing placeholders + # {position} β†’ \d+ {serial} β†’ .+ + regex_suffix = re.escape(pattern) + regex_suffix = regex_suffix.replace(re.escape("{position}"), r"\d+") + regex_suffix = regex_suffix.replace(re.escape("{serial}"), r".+") + + stripped = re.sub(regex_suffix + "$", "", name, flags=re.IGNORECASE) + return stripped if stripped != name else None + except Exception: + return None + def _get_vc_inventory_serials(self, obj): """ Fetch inventory serials for Virtual Chassis members. diff --git a/netbox_librenms_plugin/views/base/vlan_table_view.py b/netbox_librenms_plugin/views/base/vlan_table_view.py new file mode 100644 index 0000000000..78bbfb0fca --- /dev/null +++ b/netbox_librenms_plugin/views/base/vlan_table_view.py @@ -0,0 +1,212 @@ +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.constants import LIBRENMS_VLAN_STATE_ACTIVE +from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable +from netbox_librenms_plugin.views.mixins import ( + CacheMixin, + LibreNMSAPIMixin, + LibreNMSPermissionMixin, + VlanAssignmentMixin, +) + + +class BaseVLANTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View): + """ + Base view for VLAN synchronization table. + Fetches LibreNMS VLAN data and compares with NetBox. + """ + + model = None # To be defined in subclasses + partial_template_name = "netbox_librenms_plugin/_vlan_sync_content.html" + + def get_object(self, pk): + """Retrieve the object (Device or VirtualMachine).""" + return get_object_or_404(self.model, pk=pk) + + def post(self, request, pk): + """Handle POST request to fetch and cache LibreNMS VLAN data.""" + obj = self.get_object(pk) + + # Get librenms_id + self.librenms_id = self.librenms_api.get_librenms_id(obj) + + if not self.librenms_id: + messages.error(request, "Device not found in LibreNMS.") + context = {"vlan_sync": self._get_error_context(obj, "Device not found in LibreNMS")} + return render(request, self.partial_template_name, context) + + # Fetch VLAN data from LibreNMS + success, error_msg = self._fetch_and_cache_vlan_data(obj) + if not success: + messages.error(request, error_msg) + context = {"vlan_sync": self._get_error_context(obj, error_msg)} + return render(request, self.partial_template_name, context) + + messages.success(request, "VLAN data refreshed successfully.") + + context = {"vlan_sync": self.get_vlan_context(request, obj)} + return render(request, self.partial_template_name, context) + + def _fetch_and_cache_vlan_data(self, obj): + """ + Fetch VLAN data from LibreNMS and cache it. + + Returns: + tuple: (success: bool, error_message: str or None) + """ + # Fetch device VLANs + success, vlans_data = self.librenms_api.get_device_vlans(self.librenms_id) + if not success: + return False, f"Failed to fetch VLANs: {vlans_data}" + + # Cache VLANs + cache.set( + self.get_cache_key(obj, "vlans"), + vlans_data, + timeout=self.librenms_api.cache_timeout, + ) + cache.set( + self.get_last_fetched_key(obj, "vlans"), + timezone.now(), + timeout=self.librenms_api.cache_timeout, + ) + + return True, None + + def get_vlan_context(self, request, obj): + """ + Build context for VLAN sync table. + + Returns context with: + - vlan_table: LibreNMSVLANTable instance + - vlan_groups: QuerySet of available VLAN groups + """ + vlan_table = None + + # Get cached data + cached_vlans = cache.get(self.get_cache_key(obj, "vlans")) + last_fetched = cache.get(self.get_last_fetched_key(obj, "vlans")) + + # Get available VLAN groups for this device + vlan_groups = self.get_vlan_groups_for_device(obj) + + # Build lookup maps for VLAN matching + lookup_maps = self._build_vlan_lookup_maps(vlan_groups) + + if cached_vlans: + # Compare VLANs with NetBox (against all device-available VLANs) + compared_vlans = self.compare_vlans(cached_vlans, lookup_maps, device=obj) + + vlan_table = LibreNMSVLANTable(compared_vlans, vlan_groups=vlan_groups) + vlan_table.configure(request) + + # Calculate cache TTL + cache_ttl = cache.ttl(self.get_cache_key(obj, "vlans")) + cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl else None + + return { + "object": obj, + "vlan_table": vlan_table, + "vlan_groups": vlan_groups, + "last_fetched": last_fetched, + "cache_expiry": cache_expiry, + } + + def _get_error_context(self, obj, error_message): + """Build context for error state.""" + return { + "object": obj, + "error_message": error_message, + "vlan_table": None, + "vlan_groups": self.get_vlan_groups_for_device(obj), + } + + def compare_vlans(self, librenms_vlans, lookup_maps=None, device=None): + """ + Compare LibreNMS VLANs against NetBox VLANs available to the device. + + Args: + librenms_vlans: List of VLAN dicts from LibreNMS + lookup_maps: Dict with vid_to_groups, vid_group_to_vlan, vid_to_vlans + device: NetBox Device object for scope-based prioritization + + Adds comparison flags: + - exists_in_netbox: bool + - netbox_vlan: VLAN object or None + - netbox_vlan_group: VLANGroup name or None + - name_matches: bool + - auto_selected_group_id: ID of auto-selected group or None + - auto_selected_group_name: Name of auto-selected group or None + - is_ambiguous: bool - True if VID exists in multiple groups with no clear priority + """ + lookup_maps = lookup_maps or {} + vid_to_groups = lookup_maps.get("vid_to_groups", {}) + vid_to_vlans = lookup_maps.get("vid_to_vlans", {}) + + compared = [] + for vlan in librenms_vlans: + vid = vlan.get("vlan_vlan") + name = vlan.get("vlan_name", "") + + # Auto-selection logic for VLAN group dropdown + auto_selected_group_id = None + auto_selected_group_name = None + is_ambiguous = False + netbox_vlan = None + + # Check if VID exists in groups for auto-selection + if vid in vid_to_groups: + groups = vid_to_groups[vid] + if len(groups) == 1: + auto_selected_group_id = groups[0].pk + auto_selected_group_name = groups[0].name + # Get the VLAN from this single group + vlans_for_vid = vid_to_vlans.get(vid, []) + if vlans_for_vid: + netbox_vlan = vlans_for_vid[0] + elif len(groups) > 1: + # Try to select the most specific group based on device context + most_specific = self._select_most_specific_group(groups, device) + if most_specific: + auto_selected_group_id = most_specific.pk + auto_selected_group_name = most_specific.name + # Get the VLAN from the most specific group + vlans_for_vid = vid_to_vlans.get(vid, []) + for v in vlans_for_vid: + if v.group and v.group.pk == most_specific.pk: + netbox_vlan = v + break + else: + is_ambiguous = True + else: + # Check if it exists as a global VLAN (no group) + vlans_for_vid = vid_to_vlans.get(vid, []) + for v in vlans_for_vid: + if v.group is None: + netbox_vlan = v + break + + compared.append( + { + "vlan_id": vid, + "name": name, + "type": vlan.get("vlan_type", "ethernet"), + "state": vlan.get("vlan_state", LIBRENMS_VLAN_STATE_ACTIVE), + "exists_in_netbox": bool(netbox_vlan), + "netbox_vlan_id": netbox_vlan.pk if netbox_vlan else None, + "netbox_vlan_name": netbox_vlan.name if netbox_vlan else None, + "netbox_vlan_group": netbox_vlan.group.name if netbox_vlan and netbox_vlan.group else None, + "netbox_vlan_group_id": netbox_vlan.group.pk if netbox_vlan and netbox_vlan.group else None, + "name_matches": netbox_vlan.name == name if netbox_vlan else False, + # Fields for per-row VLAN group selection + "auto_selected_group_id": auto_selected_group_id, + "auto_selected_group_name": auto_selected_group_name, + "is_ambiguous": is_ambiguous, + } + ) + + return compared diff --git a/netbox_librenms_plugin/views/imports/__init__.py b/netbox_librenms_plugin/views/imports/__init__.py index 8b1f4501ae..b6d22d0e1d 100644 --- a/netbox_librenms_plugin/views/imports/__init__.py +++ b/netbox_librenms_plugin/views/imports/__init__.py @@ -8,6 +8,7 @@ DeviceRoleUpdateView, DeviceValidationDetailsView, DeviceVCDetailsView, + SaveUserPrefView, ) from .list import LibreNMSImportView # noqa: F401 @@ -20,4 +21,5 @@ "DeviceValidationDetailsView", "DeviceVCDetailsView", "LibreNMSImportView", + "SaveUserPrefView", ] diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index 82e4c6c34e..46b10b117a 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -1,10 +1,12 @@ """HTMX endpoints and POST handlers for importing LibreNMS devices.""" +import json import logging from django.contrib import messages from django.core.cache import cache -from django.http import HttpResponse +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, render from django.views import View @@ -27,7 +29,8 @@ fetch_model_by_id, ) from netbox_librenms_plugin.tables.device_status import DeviceImportTable -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.utils import save_user_pref +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin logger = logging.getLogger(__name__) @@ -204,10 +207,15 @@ def _apply_user_selections_to_validation( apply_rack_to_validation(validation, rack) -class BulkImportConfirmView(LibreNMSAPIMixin, View): +class BulkImportConfirmView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """HTMX view to confirm bulk imports before execution.""" def post(self, request): + """Render a confirmation modal for selected devices before bulk import.""" + # Check write permission before showing import confirmation + if error := self.require_write_permission(): + return error + device_ids = request.POST.getlist("select") if not device_ids: return HttpResponse( @@ -352,7 +360,7 @@ def post(self, request): ) -class BulkImportDevicesView(LibreNMSAPIMixin, View): +class BulkImportDevicesView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """Handle bulk import requests coming from the LibreNMS import table.""" def should_use_background_job_for_import(self, request): @@ -362,15 +370,26 @@ def should_use_background_job_for_import(self, request): Import jobs provide active cancellation and keep the browser responsive during bulk imports. + Note: Non-superusers automatically fall back to synchronous mode because + the /api/core/background-tasks/ endpoint requires superuser access. + Args: request: Django request object containing POST data Returns: bool: True if background job should be used, False for synchronous """ + # Non-superusers cannot poll background-tasks API (requires IsSuperuser) + if not request.user.is_superuser: + return False return request.POST.get("use_background_job") == "on" def post(self, request): # noqa: PLR0912 - branching keeps responses explicit + """Import selected devices from LibreNMS into NetBox.""" + # Check write permission before any import operation + if error := self.require_write_permission(): + return error + device_ids = request.POST.getlist("select") if not device_ids: messages.error(request, "No devices selected for import") @@ -529,6 +548,7 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, # type: ignore libre_devices_cache=libre_devices_cache_sync, + user=request.user, # Pass user for permission checks ) # Import VMs if any @@ -538,8 +558,20 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit self.librenms_api, sync_options, libre_devices_cache_sync, + user=request.user, # Pass user for permission checks ) + except PermissionDenied as exc: + # Handle permission errors with a user-friendly message + logger.warning(f"Permission denied during import: {exc}") + messages.error(request, str(exc)) + if request.headers.get("HX-Request"): + return HttpResponse( + "", + headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"}, + ) + return redirect("plugins:netbox_librenms_plugin:librenms_import") + except Exception as exc: # pragma: no cover - defensive guard logger.exception("Error during bulk import") if request.headers.get("HX-Request"): @@ -631,10 +663,11 @@ def post(self, request): # noqa: PLR0912 - branching keeps responses explicit return redirect("plugins:netbox_librenms_plugin:librenms_import") -class DeviceVCDetailsView(LibreNMSAPIMixin, View): +class DeviceVCDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """HTMX view to show virtual chassis details.""" def get(self, request, device_id): + """Render virtual chassis details for a LibreNMS device.""" libre_device = get_librenms_device_by_id(self.librenms_api, device_id) if not libre_device: return HttpResponse( @@ -656,10 +689,11 @@ def get(self, request, device_id): ) -class DeviceValidationDetailsView(LibreNMSAPIMixin, DeviceImportHelperMixin, View): +class DeviceValidationDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to show detailed validation information.""" def get(self, request, device_id): + """Render detailed validation information for a LibreNMS device.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: @@ -680,10 +714,11 @@ def get(self, request, device_id): ) -class DeviceRoleUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View): +class DeviceRoleUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a role is selected.""" def post(self, request, device_id): + """Update the table row after a device role selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: @@ -692,10 +727,11 @@ def post(self, request, device_id): return self.render_device_row(request, libre_device, validation, selections) -class DeviceClusterUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View): +class DeviceClusterUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a cluster is selected/deselected.""" def post(self, request, device_id): + """Update the table row after a cluster selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: @@ -704,13 +740,40 @@ def post(self, request, device_id): return self.render_device_row(request, libre_device, validation, selections) -class DeviceRackUpdateView(LibreNMSAPIMixin, DeviceImportHelperMixin, View): +class DeviceRackUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to update a table row when a rack is selected.""" def post(self, request, device_id): + """Update the table row after a rack selection change.""" libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) if not libre_device: return HttpResponse("Device not found", status=404) return self.render_device_row(request, libre_device, validation, selections) + + +class SaveUserPrefView(LibreNMSPermissionMixin, View): + """Save a user preference via POST. Used by JS toggle handlers.""" + + ALLOWED_PREFS = { + "use_sysname": "plugins.netbox_librenms_plugin.use_sysname", + "strip_domain": "plugins.netbox_librenms_plugin.strip_domain", + "interface_name_field": "plugins.netbox_librenms_plugin.interface_name_field", + } + + def post(self, request): + """Persist a user preference toggle value.""" + try: + data = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": "Invalid JSON"}, status=400) + + key = data.get("key") + value = data.get("value") + + if key not in self.ALLOWED_PREFS: + return JsonResponse({"error": "Invalid preference key"}, status=400) + + save_user_pref(request, self.ALLOWED_PREFS[key], value) + return JsonResponse({"status": "ok"}) diff --git a/netbox_librenms_plugin/views/imports/list.py b/netbox_librenms_plugin/views/imports/list.py index 4b1a226513..b6222a5905 100644 --- a/netbox_librenms_plugin/views/imports/list.py +++ b/netbox_librenms_plugin/views/imports/list.py @@ -15,12 +15,13 @@ ) from netbox_librenms_plugin.models import LibreNMSSettings from netbox_librenms_plugin.tables.device_status import DeviceImportTable -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.utils import get_user_pref +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin logger = logging.getLogger(__name__) -class LibreNMSImportView(LibreNMSAPIMixin, generic.ObjectListView): +class LibreNMSImportView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView): """Import devices from LibreNMS into NetBox with validation metadata.""" queryset = Device.objects.none() @@ -32,6 +33,7 @@ class LibreNMSImportView(LibreNMSAPIMixin, generic.ObjectListView): title = "Import Devices from LibreNMS" def get_required_permission(self): + """Return the permission required to view the import list.""" from utilities.permissions import get_permission_for_model return get_permission_for_model(Device, "view") @@ -49,9 +51,15 @@ def should_use_background_job(self): - Job tracking in NetBox Jobs interface - Results cached for later retrieval + Note: Non-superusers automatically fall back to synchronous mode because + the /api/core/background-tasks/ endpoint requires superuser access. + Returns: bool: True if background job should be used, False for synchronous """ + # Non-superusers cannot poll background-tasks API (requires IsSuperuser) + if not self.request.user.is_superuser: + return False return self._filter_form_data.get("use_background_job", True) def _load_job_results(self, job_id): @@ -299,6 +307,15 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc except Exception: 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) @@ -311,6 +328,8 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc "filters_submitted": filters_submitted, "show_filter_warning": bool(filter_warning), "settings": settings, + "use_sysname": use_sysname, + "strip_domain": strip_domain, "vc_detection_enabled": getattr(self, "_vc_detection_enabled", False), "cache_cleared": getattr(self, "_cache_cleared", False), "from_cache": getattr(self, "_from_cache", False), @@ -319,20 +338,26 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc "cache_metadata_missing": getattr(self, "_cache_metadata_missing", False), "cached_searches": cached_searches, "librenms_server_info": self.get_server_info(), + "can_use_background_jobs": request.user.is_superuser, } return render(request, self.template_name, context) def get_queryset(self, request): # noqa: D401 - inherited doc + """Load import data into _import_data and return an empty Device queryset.""" import_data = self._get_import_queryset() self._import_data = import_data return Device.objects.none() def get_table(self, data, request, bulk_actions=True): + """Return a DeviceImportTable populated with validated import data.""" if not hasattr(self, "_import_data"): self._import_data = self._get_import_queryset() data = self._import_data - table = DeviceImportTable(data, order_by=request.GET.get("sort")) + table = DeviceImportTable( + data, + order_by=request.GET.get("sort"), + ) return table def _get_import_queryset(self): diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 2252efd835..b1fcec9c77 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -9,9 +9,10 @@ ) from netbox_librenms_plugin.models import InterfaceTypeMapping from netbox_librenms_plugin.tables.mappings import InterfaceTypeMappingTable +from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin -class InterfaceTypeMappingListView(generic.ObjectListView): +class InterfaceTypeMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """ Provides a view for listing all `InterfaceTypeMapping` objects. """ @@ -23,7 +24,7 @@ class InterfaceTypeMappingListView(generic.ObjectListView): template_name = "netbox_librenms_plugin/interfacetypemapping_list.html" -class InterfaceTypeMappingCreateView(generic.ObjectEditView): +class InterfaceTypeMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView): """ Provides a view for creating a new `InterfaceTypeMapping` object. """ @@ -33,7 +34,7 @@ class InterfaceTypeMappingCreateView(generic.ObjectEditView): @register_model_view(InterfaceTypeMapping, "bulk_import", path="import", detail=False) -class InterfaceTypeMappingBulkImportView(generic.BulkImportView): +class InterfaceTypeMappingBulkImportView(LibreNMSPermissionMixin, generic.BulkImportView): """ Provides a view for bulk importing `InterfaceTypeMapping` objects from CSV, JSON, or YAML. Supports three import methods: direct import, file upload, and data file. @@ -43,7 +44,7 @@ class InterfaceTypeMappingBulkImportView(generic.BulkImportView): model_form = InterfaceTypeMappingImportForm -class InterfaceTypeMappingView(generic.ObjectView): +class InterfaceTypeMappingView(LibreNMSPermissionMixin, generic.ObjectView): """ Provides a view for displaying details of a specific `InterfaceTypeMapping` object. """ @@ -51,7 +52,7 @@ class InterfaceTypeMappingView(generic.ObjectView): queryset = InterfaceTypeMapping.objects.all() -class InterfaceTypeMappingEditView(generic.ObjectEditView): +class InterfaceTypeMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView): """ Provides a view for editing a specific `InterfaceTypeMapping` object. """ @@ -60,7 +61,7 @@ class InterfaceTypeMappingEditView(generic.ObjectEditView): form = InterfaceTypeMappingForm -class InterfaceTypeMappingDeleteView(generic.ObjectDeleteView): +class InterfaceTypeMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView): """ Provides a view for deleting a specific `InterfaceTypeMapping` object. """ @@ -68,7 +69,7 @@ class InterfaceTypeMappingDeleteView(generic.ObjectDeleteView): queryset = InterfaceTypeMapping.objects.all() -class InterfaceTypeMappingBulkDeleteView(generic.BulkDeleteView): +class InterfaceTypeMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView): """ Provides a view for deleting multiple `InterfaceTypeMapping` objects. """ @@ -77,7 +78,7 @@ class InterfaceTypeMappingBulkDeleteView(generic.BulkDeleteView): table = InterfaceTypeMappingTable -class InterfaceTypeMappingChangeLogView(generic.ObjectChangeLogView): +class InterfaceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): """ Provides a view for displaying the change log of a specific `InterfaceTypeMapping` object. """ diff --git a/netbox_librenms_plugin/views/mixins.py b/netbox_librenms_plugin/views/mixins.py index 49795fe153..73513f88df 100644 --- a/netbox_librenms_plugin/views/mixins.py +++ b/netbox_librenms_plugin/views/mixins.py @@ -1,6 +1,198 @@ +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import HttpResponse +from django.shortcuts import redirect +from django.utils.http import url_has_allowed_host_and_scheme +from utilities.permissions import get_permission_for_model + +from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN from netbox_librenms_plugin.librenms_api import LibreNMSAPI +def _get_safe_redirect_url(request): + """Return a validated redirect URL from the HTTP Referer header. + + Validates the Referer against allowed hosts and schemes to prevent + open-redirect attacks. Falls back to the current request path or "/". + """ + referrer = request.META.get("HTTP_REFERER") + if referrer and url_has_allowed_host_and_scheme( + referrer, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + return referrer + return getattr(request, "path", "/") + + +class LibreNMSPermissionMixin(PermissionRequiredMixin): + """ + Mixin for views requiring LibreNMS plugin permissions. + + All plugin views require 'view_librenmssettings' to access the page. + Write actions require 'change_librenmssettings' plus any relevant + NetBox object permissions. + """ + + permission_required = PERM_VIEW_PLUGIN + + def has_write_permission(self): + """Check if user can perform write actions.""" + return self.request.user.has_perm(PERM_CHANGE_PLUGIN) + + def require_write_permission(self, error_message=None): + """ + Check write permission and return error response if denied. + + Handles both HTMX and regular requests appropriately: + - HTMX: Returns HX-Redirect to referrer with toast message + - Regular: Returns redirect to referrer with flash message + + Returns: + None if permitted, or appropriate response if denied + """ + if not self.has_write_permission(): + msg = error_message or "You do not have permission to perform this action." + messages.error(self.request, msg) + + referrer = _get_safe_redirect_url(self.request) + + # Check if this is an HTMX request + if self.request.headers.get("HX-Request"): + return HttpResponse("", headers={"HX-Redirect": referrer}) + + return redirect(referrer) + return None + + def require_write_permission_json(self, error_message=None): + """ + Check write permission and return JSON error response if denied. + + Use this method for AJAX/HTMX endpoints that return JsonResponse. + Does not set flash messages since JSON clients handle errors differently. + + Returns: + None if permitted, or JsonResponse with 403 status if denied + """ + from django.http import JsonResponse + + if not self.has_write_permission(): + msg = error_message or "You do not have permission to perform this action." + return JsonResponse({"error": msg}, status=403) + return None + + +class NetBoxObjectPermissionMixin: + """ + Mixin for views requiring specific NetBox object permissions. + + Define required_object_permissions as a dict mapping HTTP methods + to lists of (action, model) tuples. + + Example: + required_object_permissions = { + 'POST': [ + ('add', Interface), + ('change', Interface), + ], + } + """ + + required_object_permissions = {} + + def check_object_permissions(self, method): + """ + Check all required object permissions for the given HTTP method. + + Args: + method: HTTP method (GET, POST, etc.) + + Returns: + tuple: (has_all: bool, missing: list[str]) + """ + requirements = self.required_object_permissions.get(method, []) + missing = [] + + for action, model in requirements: + perm = get_permission_for_model(model, action) + if not self.request.user.has_perm(perm): + missing.append(perm) + + return (len(missing) == 0, missing) + + def require_object_permissions(self, method): + """ + Require all object permissions for the method, returning error response if denied. + + Handles both HTMX and regular requests appropriately: + - HTMX: Returns HX-Redirect to referrer with flash message + - Regular: Returns redirect to referrer with flash message + + Returns: + None if permitted, or appropriate response if denied + """ + has_perms, missing = self.check_object_permissions(method) + if not has_perms: + missing_str = ", ".join(missing) + msg = f"Missing permissions: {missing_str}" + messages.error(self.request, msg) + + referrer = _get_safe_redirect_url(self.request) + + # Check if this is an HTMX request + if self.request.headers.get("HX-Request"): + return HttpResponse("", headers={"HX-Redirect": referrer}) + + return redirect(referrer) + return None + + def require_object_permissions_json(self, method): + """ + Require all object permissions for the method, returning JSON error if denied. + + Use this method for AJAX/HTMX endpoints that return JsonResponse. + Does not set flash messages since JSON clients handle errors differently. + + Returns: + None if permitted, or JsonResponse with 403 status if denied + """ + from django.http import JsonResponse + + has_perms, missing = self.check_object_permissions(method) + if not has_perms: + missing_str = ", ".join(missing) + return JsonResponse({"error": f"Missing permissions: {missing_str}"}, status=403) + return None + + def require_all_permissions(self, method="POST"): + """ + Check both plugin write and NetBox object permissions. + + Combines require_write_permission() and require_object_permissions() + into a single call. Handles HTMX and regular requests. + + Returns: + None if permitted, or appropriate error response if denied + """ + if error := self.require_write_permission(): + return error + return self.require_object_permissions(method) + + def require_all_permissions_json(self, method="POST"): + """ + Check both plugin write and NetBox object permissions, returning JSON errors. + + Combines require_write_permission_json() and require_object_permissions_json() + into a single call for JSON/AJAX endpoints. + + Returns: + None if permitted, or JsonResponse with 403 status if denied + """ + if error := self.require_write_permission_json(): + return error + return self.require_object_permissions_json(method) + + class LibreNMSAPIMixin: """ A mixin class that provides access to the LibreNMS API. @@ -112,3 +304,378 @@ def get_last_fetched_key(self, obj, data_type="ports"): """ model_name = obj._meta.model_name return f"librenms_{data_type}_last_fetched_{model_name}_{obj.pk}" + + def get_vlan_overrides_key(self, obj): + """ + Get the cache key for user VLAN group override selections. + + Stores a {vid_str: group_id_str} map so that "apply to all" VLAN + group choices persist across table pages. + """ + model_name = obj._meta.model_name + return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}" + + +class VlanAssignmentMixin: + """ + Mixin providing VLAN assignment utilities for views. + + Provides methods for: + - Getting relevant VLAN groups for a device based on scope hierarchy + - Building lookup maps for VLAN matching + - Selecting the most specific VLAN group based on device context + - Finding VLANs by VID within a specific group + - Updating interface VLAN assignments + """ + + def get_vlan_groups_for_device(self, device): + """ + Get all VLAN groups relevant to this device. + + Searches for VLAN groups scoped to: + - Site: The device's assigned site + - Location: The device's location and all parent locations + - Region: The device's site's region and all parent regions + - Site Group: The device's site's group and all parent site groups + - Rack: The device's rack + - Global: VLAN groups with no scope + + Returns: + List of VLANGroup objects, deduplicated and sorted by name + """ + from dcim.models import Location, Rack, Region, Site, SiteGroup + from ipam.models import VLANGroup + + groups = set() + + # Site-scoped VLAN groups + if hasattr(device, "site") and device.site: + site_groups = self._get_vlan_groups_for_scope(Site, [device.site]) + groups.update(site_groups) + + # Region-scoped VLAN groups (site's region and ancestors) + if device.site.region: + region_ancestors = self._get_ancestors(device.site.region) + region_groups = self._get_vlan_groups_for_scope(Region, region_ancestors) + groups.update(region_groups) + + # Site Group-scoped VLAN groups (site's group and ancestors) + if device.site.group: + site_group_ancestors = self._get_ancestors(device.site.group) + site_group_groups = self._get_vlan_groups_for_scope(SiteGroup, site_group_ancestors) + groups.update(site_group_groups) + + # Location-scoped VLAN groups (device's location and ancestors) + if hasattr(device, "location") and device.location: + location_ancestors = self._get_ancestors(device.location) + location_groups = self._get_vlan_groups_for_scope(Location, location_ancestors) + groups.update(location_groups) + + # Rack-scoped VLAN groups + if hasattr(device, "rack") and device.rack: + rack_groups = self._get_vlan_groups_for_scope(Rack, [device.rack]) + groups.update(rack_groups) + + # Global VLAN groups (no scope) + global_groups = VLANGroup.objects.filter(scope_type__isnull=True) + groups.update(global_groups) + + # Return sorted by name for consistent display + return sorted(groups, key=lambda g: g.name.lower()) + + def _build_vlan_lookup_maps(self, vlan_groups): + """ + Build lookup dictionaries for VLAN matching. + + Returns a dict with: + - vid_to_groups: {vid: [vlan_group, ...]} - VID to groups containing that VID + - vid_group_to_vlan: {(vid, group_id): vlan} - unique per group lookup + - vid_to_vlans: {vid: [vlan, ...]} - all VLANs with that VID + - vid_name_to_vlan: {(vid, name): vlan} - VID + name lookup + """ + from ipam.models import VLAN + + vid_to_groups = {} + vid_group_to_vlan = {} + vid_to_vlans = {} + vid_name_to_vlan = {} + + # Get all VLANs from relevant groups and global VLANs + group_pks = [g.pk for g in vlan_groups] + vlans = VLAN.objects.filter(group__pk__in=group_pks).select_related("group") + # Also get global VLANs (no group) + global_vlans = VLAN.objects.filter(group__isnull=True) + + for vlan in list(vlans) + list(global_vlans): + vid = vlan.vid + group = vlan.group + group_id = group.pk if group else None + name = vlan.name + + # Build VID to groups lookup for ambiguity detection + if vid not in vid_to_groups: + vid_to_groups[vid] = [] + if group and group not in vid_to_groups[vid]: + vid_to_groups[vid].append(group) + + # Build (vid, group_id) to vlan lookup + vid_group_to_vlan[(vid, group_id)] = vlan + + # Build VID to all VLANs list (for dropdown options) + if vid not in vid_to_vlans: + vid_to_vlans[vid] = [] + vid_to_vlans[vid].append(vlan) + + # Build (vid, name) to vlan lookup + vid_name_to_vlan[(vid, name)] = vlan + + return { + "vid_to_groups": vid_to_groups, + "vid_group_to_vlan": vid_group_to_vlan, + "vid_to_vlans": vid_to_vlans, + "vid_name_to_vlan": vid_name_to_vlan, + } + + def _select_most_specific_group(self, groups, device): + """ + Select the most specific VLAN group based on device context. + + Priority order (most specific to least specific): + 1. Rack-scoped (device's rack) + 2. Location-scoped (device's location, closer ancestors win) + 3. Site-scoped (device's site) + 4. Site Group-scoped (device's site's group, closer ancestors win) + 5. Region-scoped (device's site's region, closer ancestors win) + 6. Global (no scope) + + Args: + groups: List of VLANGroup objects that all contain the same VID + device: NetBox Device object + + Returns: + VLANGroup or None if no clear winner (e.g., multiple groups at same priority level) + """ + from dcim.models import Location, Rack, Region, Site, SiteGroup + from django.contrib.contenttypes.models import ContentType + + if not device or not groups: + return None + + # Build scope priority lookup for this device + # Lower number = higher priority (more specific) + scope_priority = {} + priority = 0 + + # Priority 1: Rack (most specific) + if hasattr(device, "rack") and device.rack: + rack_ct = ContentType.objects.get_for_model(Rack) + scope_priority[(rack_ct.pk, device.rack.pk)] = priority + priority += 1 + + # Priority 2: Location hierarchy (device's location first, then ancestors) + if hasattr(device, "location") and device.location: + location_ct = ContentType.objects.get_for_model(Location) + for loc in self._get_ancestors(device.location): + scope_priority[(location_ct.pk, loc.pk)] = priority + priority += 1 + + # Priority 3: Site + if hasattr(device, "site") and device.site: + site_ct = ContentType.objects.get_for_model(Site) + scope_priority[(site_ct.pk, device.site.pk)] = priority + priority += 1 + + # Priority 4: Site Group hierarchy + if device.site.group: + site_group_ct = ContentType.objects.get_for_model(SiteGroup) + for sg in self._get_ancestors(device.site.group): + scope_priority[(site_group_ct.pk, sg.pk)] = priority + priority += 1 + + # Priority 5: Region hierarchy + if device.site.region: + region_ct = ContentType.objects.get_for_model(Region) + for reg in self._get_ancestors(device.site.region): + scope_priority[(region_ct.pk, reg.pk)] = priority + priority += 1 + + # Priority 6: Global (no scope) - lowest priority + global_priority = priority + + # Find the group with the highest priority (lowest number) + best_group = None + best_priority = float("inf") + same_priority_count = 0 + + for group in groups: + if group.scope_type is None: + # Global scope + group_priority = global_priority + else: + scope_key = (group.scope_type.pk, group.scope_id) + group_priority = scope_priority.get(scope_key, float("inf")) + + if group_priority < best_priority: + best_priority = group_priority + best_group = group + same_priority_count = 1 + elif group_priority == best_priority: + same_priority_count += 1 + + # Only return a group if there's a single winner at the best priority level + if same_priority_count == 1 and best_group is not None: + return best_group + + return None + + def _get_ancestors(self, obj): + """ + Get all ancestors of a hierarchical object (location, region, site group). + Returns list including the object itself and all parents up to root. + """ + ancestors = [] + current = obj + while current is not None: + ancestors.append(current) + current = getattr(current, "parent", None) + return ancestors + + def _get_vlan_groups_for_scope(self, model_class, objects): + """ + Get VLAN groups scoped to any of the given objects. + + Args: + model_class: The Django model class (Site, Location, Region, etc.) + objects: List of model instances to check + + Returns: + QuerySet of VLANGroup objects + """ + from django.contrib.contenttypes.models import ContentType + from ipam.models import VLANGroup + + if not objects: + return VLANGroup.objects.none() + + content_type = ContentType.objects.get_for_model(model_class) + object_ids = [obj.pk for obj in objects if obj is not None] + + if not object_ids: + return VLANGroup.objects.none() + + return VLANGroup.objects.filter(scope_type=content_type, scope_id__in=object_ids) + + def _find_vlan_in_group(self, vid, vlan_group_id, lookup_maps): + """ + Find a VLAN by VID, preferring the specified group. + + Args: + vid: VLAN ID (integer) + vlan_group_id: Optional VLAN group ID to prefer + lookup_maps: Dict from _build_vlan_lookup_maps() + + Returns: + VLAN object or None + """ + vid_group_to_vlan = lookup_maps.get("vid_group_to_vlan", {}) + vid_to_vlans = lookup_maps.get("vid_to_vlans", {}) + + # Try specific group first + if vlan_group_id: + try: + vlan = vid_group_to_vlan.get((vid, int(vlan_group_id))) + if vlan: + return vlan + except (ValueError, TypeError): + pass + + # Try global (no group) + vlan = vid_group_to_vlan.get((vid, None)) + if vlan: + return vlan + + # Fallback: first matching VLAN + vlans = vid_to_vlans.get(vid, []) + return vlans[0] if vlans else None + + def _update_interface_vlan_assignment(self, interface, vlan_data, vlan_group_map, lookup_maps): + """ + Update interface VLAN assignments in NetBox (mode, untagged_vlan, tagged_vlans). + + Args: + interface: NetBox Interface or VMInterface object + vlan_data: Dict with 'untagged_vlan' (int or None) and 'tagged_vlans' (list of ints) + vlan_group_map: Dict mapping VID (str) to VLAN group ID for per-VLAN group lookups. + Can also be a single group ID string for backward compat. + lookup_maps: Dict from _build_vlan_lookup_maps() + + Returns: + Dict with sync results: + - mode_set: str or None + - untagged_set: VLAN object or None + - tagged_set: list of VLAN objects + - missing_vlans: list of VIDs not found in NetBox + """ + # Support both dict (per-VLAN) and string/int/None (single group) for backward compat + if not isinstance(vlan_group_map, dict): + single_group_id = vlan_group_map + vlan_group_map = None + else: + single_group_id = None + + untagged_vid = vlan_data.get("untagged_vlan") + tagged_vids = vlan_data.get("tagged_vlans", []) + missing_vlans = [] + + def _get_group_id_for_vid(vid): + """Resolve the VLAN group ID for a specific VID.""" + if vlan_group_map is not None: + return vlan_group_map.get(str(vid), "") + return single_group_id or "" + + # Determine mode + if tagged_vids: + interface.mode = "tagged" + elif untagged_vid: + interface.mode = "access" + else: + # No VLANs - clear mode + interface.mode = "" + + # Set untagged VLAN + untagged_set = None + if untagged_vid: + vlan = self._find_vlan_in_group(untagged_vid, _get_group_id_for_vid(untagged_vid), lookup_maps) + if vlan: + interface.untagged_vlan = vlan + untagged_set = vlan + else: + missing_vlans.append(untagged_vid) + interface.untagged_vlan = None + else: + interface.untagged_vlan = None + + # Save mode + untagged_vlan before M2M operations. + # tagged_vlans.set() triggers a DB refresh that wipes unsaved + # in-memory attributes, so we must persist first. + interface.save() + + # Set tagged VLANs (M2M - requires the instance to be saved first) + tagged_set = [] + if tagged_vids: + for vid in tagged_vids: + vlan = self._find_vlan_in_group(vid, _get_group_id_for_vid(vid), lookup_maps) + if vlan: + tagged_set.append(vlan) + else: + missing_vlans.append(vid) + interface.tagged_vlans.set(tagged_set) + else: + interface.tagged_vlans.clear() + + return { + "mode_set": interface.mode, + "untagged_set": untagged_set, + "tagged_set": tagged_set, + "missing_vlans": missing_vlans, + } diff --git a/netbox_librenms_plugin/views/object_sync/__init__.py b/netbox_librenms_plugin/views/object_sync/__init__.py index 365e1f1634..e9893cf7d8 100644 --- a/netbox_librenms_plugin/views/object_sync/__init__.py +++ b/netbox_librenms_plugin/views/object_sync/__init__.py @@ -5,7 +5,11 @@ DeviceInterfaceTableView, DeviceIPAddressTableView, DeviceLibreNMSSyncView, + DeviceVLANTableView, + SaveVlanGroupOverridesView, SingleInterfaceVerifyView, + SingleVlanGroupVerifyView, + VerifyVlanSyncGroupView, ) from .vms import ( # noqa: F401 VMInterfaceTableView, diff --git a/netbox_librenms_plugin/views/object_sync/devices.py b/netbox_librenms_plugin/views/object_sync/devices.py index 79880658da..97584f8319 100644 --- a/netbox_librenms_plugin/views/object_sync/devices.py +++ b/netbox_librenms_plugin/views/object_sync/devices.py @@ -8,6 +8,7 @@ from django.views import View from utilities.views import ViewTab, register_model_view +from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN from netbox_librenms_plugin.tables.cables import ( LibreNMSCableTable, VCCableTable, @@ -16,13 +17,20 @@ LibreNMSInterfaceTable, VCInterfaceTable, ) -from netbox_librenms_plugin.utils import get_interface_name_field +from netbox_librenms_plugin.utils import ( + get_interface_name_field, + get_missing_vlan_warning, + get_tagged_vlan_css_class, + get_untagged_vlan_css_class, + get_vlan_sync_css_class, +) from ..base.cables_view import BaseCableTableView from ..base.interfaces_view import BaseInterfaceTableView from ..base.ip_addresses_view import BaseIPAddressTableView from ..base.librenms_sync_view import BaseLibreNMSSyncView -from ..mixins import CacheMixin +from ..base.vlan_table_view import BaseVLANTableView +from ..mixins import CacheMixin, LibreNMSPermissionMixin @register_model_view(Device, name="librenms_sync", path="librenms-sync") @@ -31,22 +39,30 @@ class DeviceLibreNMSSyncView(BaseLibreNMSSyncView): queryset = Device.objects.all() model = Device - tab = ViewTab(label="LibreNMS Sync", permission="dcim.view_device") + tab = ViewTab(label="LibreNMS Sync", permission=PERM_VIEW_PLUGIN) def get_interface_context(self, request, obj): + """Return interface sync context for the device.""" interface_name_field = get_interface_name_field(request) interface_table_view = DeviceInterfaceTableView() interface_table_view.request = request return interface_table_view.get_context_data(request, obj, interface_name_field) def get_cable_context(self, request, obj): + """Return cable sync context for the device.""" cable_table_view = DeviceCableTableView() return cable_table_view.get_context_data(request, obj) def get_ip_context(self, request, obj): + """Return IP address sync context for the device.""" ipaddress_table_view = DeviceIPAddressTableView() return ipaddress_table_view.get_context_data(request, obj) + def get_vlan_context(self, request, obj): + vlan_table_view = DeviceVLANTableView() + vlan_table_view.request = request + return vlan_table_view.get_vlan_context(request, obj) + class DeviceInterfaceTableView(BaseInterfaceTableView): """Interface synchronization table for Devices.""" @@ -54,24 +70,32 @@ class DeviceInterfaceTableView(BaseInterfaceTableView): model = Device def get_interfaces(self, obj): + """Return all interfaces for the device.""" return obj.interfaces.all() def get_redirect_url(self, obj): - return reverse("plugins:netbox_librenms_plugin:vm_interface_sync", kwargs={"pk": obj.pk}) + """Return the device interface sync redirect URL.""" + return reverse("plugins:netbox_librenms_plugin:device_interface_sync", kwargs={"pk": obj.pk}) - def get_table(self, data, obj, interface_name_field): + def get_table(self, data, obj, interface_name_field, vlan_groups=None): + """Return the appropriate interface table, selecting VC variant if needed.""" if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: - table = VCInterfaceTable(data, device=obj, interface_name_field=interface_name_field) + table = VCInterfaceTable( + data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups + ) else: - table = LibreNMSInterfaceTable(data, device=obj, interface_name_field=interface_name_field) + table = LibreNMSInterfaceTable( + data, device=obj, interface_name_field=interface_name_field, vlan_groups=vlan_groups + ) table.htmx_url = f"{self.request.path}?tab=interfaces" return table -class SingleInterfaceVerifyView(CacheMixin, View): +class SingleInterfaceVerifyView(LibreNMSPermissionMixin, CacheMixin, View): """Verify single interface data for a device via cached LibreNMS payload.""" def post(self, request): + """Verify interface data against cached LibreNMS ports for a device.""" data = json.loads(request.body) selected_device_id = data.get("device_id") interface_name = data.get("interface_name") @@ -113,12 +137,229 @@ def post(self, request): return JsonResponse({"status": "error", "message": "Interface data not found"}, status=404) +class SingleVlanGroupVerifyView(LibreNMSPermissionMixin, CacheMixin, View): + """ + Verify VLAN assignments for an interface against a specific VLAN group. + + When user changes the VLAN group dropdown, this endpoint re-computes + which VLANs are "missing" (don't exist in selected group) and returns + updated HTML for the VLANs cell with correct colors. + """ + + def post(self, request): + from ipam.models import VLAN, VLANGroup + + data = json.loads(request.body) + device_id = data.get("device_id") + interface_name = data.get("interface_name") + vlan_group_id = data.get("vlan_group_id") + vlan_type = data.get("vlan_type", "U") # "U" or "T" + vid_str = data.get("vid", "") or data.get("untagged_vlan", "") + + if not device_id: + return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400) + if not vid_str: + return JsonResponse({"status": "error", "message": "No VID provided"}, status=400) + + device = get_object_or_404(Device, pk=device_id) + try: + vid = int(vid_str) + except (ValueError, TypeError): + return JsonResponse({"status": "error", "message": "Invalid VID"}, status=400) + + # Build lookup for the selected group + if vlan_group_id: + vlan_group = get_object_or_404(VLANGroup, pk=vlan_group_id) + # Get VLANs in selected group + global VLANs + group_vids = set(VLAN.objects.filter(group=vlan_group).values_list("vid", flat=True)) + global_vids = set(VLAN.objects.filter(group__isnull=True).values_list("vid", flat=True)) + available_vids = group_vids | global_vids + else: + # No group selected - use global VLANs only + available_vids = set(VLAN.objects.filter(group__isnull=True).values_list("vid", flat=True)) + + # Compute whether VID is missing from selected group + is_missing = vid not in available_vids + missing_vlans = [vid] if is_missing else [] + + # Get NetBox interface for comparison + netbox_interface = device.interfaces.filter(name=interface_name).first() + exists_in_netbox = bool(netbox_interface) + + # Get NetBox VLAN assignments (VID + group for group-aware comparison) + netbox_untagged_vid = None + netbox_untagged_group_id = None + netbox_tagged_vids = set() + netbox_tagged_group_ids = {} + if netbox_interface: + if netbox_interface.untagged_vlan: + netbox_untagged_vid = netbox_interface.untagged_vlan.vid + netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id + for v in netbox_interface.tagged_vlans.all(): + netbox_tagged_vids.add(v.vid) + netbox_tagged_group_ids[v.vid] = v.group_id + + # Determine group match: selected group vs NetBox VLAN's actual group + selected_gid = int(vlan_group_id) if vlan_group_id else None + + # Determine CSS class based on actual VLAN type + if vlan_type == "U": + # Group matches only matters when VIDs match + group_matches = (netbox_untagged_group_id == selected_gid) if netbox_untagged_vid == vid else True + css_class = get_untagged_vlan_css_class( + vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches + ) + else: + netbox_gid = netbox_tagged_group_ids.get(vid) + group_matches = (netbox_gid == selected_gid) if vid in netbox_tagged_vids else True + css_class = get_tagged_vlan_css_class( + vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches + ) + + # Also render formatted HTML for backward compatibility + formatted_vlans = self._render_vlans_cell( + vid if vlan_type == "U" else None, + [vid] if vlan_type == "T" else [], + missing_vlans, + exists_in_netbox, + netbox_untagged_vid, + netbox_tagged_vids, + ) + + return JsonResponse( + { + "status": "success", + "formatted_vlans": formatted_vlans, + "css_class": css_class, + "is_missing": is_missing, + } + ) + + def _render_vlans_cell( + self, untagged, tagged, missing_vlans, exists_in_netbox, netbox_untagged_vid, netbox_tagged_vids + ): + """ + Render the VLANs cell HTML with correct color coding. + + Reuses the same color logic as LibreNMSInterfaceTable.render_vlans(). + """ + from django.utils.safestring import mark_safe + + parts = [] + + if untagged: + css = get_untagged_vlan_css_class(untagged, netbox_untagged_vid, exists_in_netbox, missing_vlans) + warning = get_missing_vlan_warning(untagged, missing_vlans) + parts.append(f'{untagged}(U){warning}') + + for vid in sorted(tagged): + css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans) + warning = get_missing_vlan_warning(vid, missing_vlans) + parts.append(f'{vid}(T){warning}') + + if not parts: + return "β€”" + + return mark_safe(", ".join(parts)) + + +class VerifyVlanSyncGroupView(LibreNMSPermissionMixin, View): + """ + Verify whether a VLAN (by VID) exists in a selected VLAN group. + + Called from the VLAN sync tab when the user changes the per-row + VLAN group dropdown. Returns the correct CSS class so the JS can + update row colors without a full page reload. + """ + + def post(self, request): + from ipam.models import VLAN, VLANGroup + + data = json.loads(request.body) + vlan_group_id = data.get("vlan_group_id") + vid_str = data.get("vid", "") + librenms_name = data.get("name", "") + + if not vid_str: + return JsonResponse({"status": "error", "message": "No VID provided"}, status=400) + + try: + vid = int(vid_str) + except (ValueError, TypeError): + return JsonResponse({"status": "error", "message": "Invalid VID"}, status=400) + + # Check if VLAN exists in the selected group (or globally) + if vlan_group_id: + vlan_group = get_object_or_404(VLANGroup, pk=vlan_group_id) + netbox_vlan = VLAN.objects.filter(vid=vid, group=vlan_group).first() + else: + # No group = global VLANs + netbox_vlan = VLAN.objects.filter(vid=vid, group__isnull=True).first() + + exists_in_netbox = bool(netbox_vlan) + name_matches = netbox_vlan.name == librenms_name if netbox_vlan else False + css_class = get_vlan_sync_css_class(exists_in_netbox, name_matches) + + return JsonResponse( + { + "status": "success", + "exists_in_netbox": exists_in_netbox, + "name_matches": name_matches, + "css_class": css_class, + "netbox_vlan_name": netbox_vlan.name if netbox_vlan else None, + } + ) + + +class SaveVlanGroupOverridesView(LibreNMSPermissionMixin, CacheMixin, View): + """ + Persist user VLAN-group-override selections in cache. + + When the user edits VLAN group assignments in the modal and checks + "Apply to all interfaces", the JS posts the {vid: group_id} map here + so that subsequent table pages render with the same choices. + The overrides are stored with the same remaining TTL as the ports + cache so they expire together. + """ + + def post(self, request): + # Require plugin write permission to persist VLAN group overrides + if error := self.require_write_permission_json(): + return error + + data = json.loads(request.body) + device_id = data.get("device_id") + vid_group_map = data.get("vid_group_map", {}) + + if not device_id: + return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400) + + device = get_object_or_404(Device, pk=device_id) + + # Use the remaining TTL of the ports cache so both expire together + ports_ttl = cache.ttl(self.get_cache_key(device, "ports")) + if ports_ttl is None or ports_ttl <= 0: + return JsonResponse( + {"status": "error", "message": "No cached port data; refresh interfaces first"}, + status=400, + ) + + # Merge with any existing overrides (user may save multiple times) + existing = cache.get(self.get_vlan_overrides_key(device)) or {} + existing.update(vid_group_map) + + cache.set(self.get_vlan_overrides_key(device), existing, timeout=ports_ttl) + + return JsonResponse({"status": "success"}) + + class DeviceCableTableView(BaseCableTableView): """Cable synchronization view for Devices.""" model = Device def get_table(self, data, obj): + """Return the appropriate cable table, selecting VC variant if needed.""" if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: return VCCableTable(data, device=obj) return LibreNMSCableTable(data, device=obj) @@ -128,3 +369,9 @@ class DeviceIPAddressTableView(BaseIPAddressTableView): """IP address synchronization view for Devices.""" model = Device + + +class DeviceVLANTableView(BaseVLANTableView): + """VLAN synchronization table view for Devices.""" + + model = Device diff --git a/netbox_librenms_plugin/views/object_sync/vms.py b/netbox_librenms_plugin/views/object_sync/vms.py index 3ce126837f..51143d909d 100644 --- a/netbox_librenms_plugin/views/object_sync/vms.py +++ b/netbox_librenms_plugin/views/object_sync/vms.py @@ -2,6 +2,7 @@ from utilities.views import ViewTab, register_model_view from virtualization.models import VirtualMachine +from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN from netbox_librenms_plugin.tables.interfaces import LibreNMSVMInterfaceTable from netbox_librenms_plugin.utils import get_interface_name_field @@ -18,18 +19,21 @@ class VMLibreNMSSyncView(BaseLibreNMSSyncView): model = VirtualMachine tab = ViewTab( label="LibreNMS Sync", - permission="virtualization.view_virtualmachine", + permission=PERM_VIEW_PLUGIN, ) def get_interface_context(self, request, obj): + """Return interface sync context for the virtual machine.""" interface_name_field = get_interface_name_field(request) interface_sync_view = VMInterfaceTableView() return interface_sync_view.get_context_data(request, obj, interface_name_field) def get_cable_context(self, request, obj): + """Return None; VMs do not support cable sync.""" return None # VMs do not expose cable sync data def get_ip_context(self, request, obj): + """Return IP address sync context for the virtual machine.""" ipaddress_sync_view = VMIPAddressTableView() return ipaddress_sync_view.get_context_data(request, obj) @@ -39,13 +43,16 @@ class VMInterfaceTableView(BaseInterfaceTableView): model = VirtualMachine - def get_table(self, data, obj, interface_name_field): - return LibreNMSVMInterfaceTable(data) + 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) def get_interfaces(self, obj): + """Return all interfaces for the virtual machine.""" return obj.interfaces.all() def get_redirect_url(self, obj): + """Return the VM interface sync redirect URL.""" return reverse("plugins:netbox_librenms_plugin:vm_interface_sync", kwargs={"pk": obj.pk}) diff --git a/netbox_librenms_plugin/views/settings_views.py b/netbox_librenms_plugin/views/settings_views.py index 6bcbf47b28..a6a7585b8f 100644 --- a/netbox_librenms_plugin/views/settings_views.py +++ b/netbox_librenms_plugin/views/settings_views.py @@ -1,21 +1,26 @@ +import logging + from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.http import HttpResponse from django.shortcuts import redirect, render +from django.utils.html import escape from django.views import View from netbox_librenms_plugin.forms import ImportSettingsForm, ServerConfigForm from netbox_librenms_plugin.librenms_api import LibreNMSAPI from netbox_librenms_plugin.models import LibreNMSSettings +from netbox_librenms_plugin.utils import save_user_pref +from netbox_librenms_plugin.views.mixins import LibreNMSPermissionMixin + +logger = logging.getLogger(__name__) -class LibreNMSSettingsView(PermissionRequiredMixin, View): +class LibreNMSSettingsView(LibreNMSPermissionMixin, View): """ View for managing plugin settings including server selection and import options. Uses two separate forms for cleaner validation and separation of concerns. """ - permission_required = "netbox_librenms_plugin.change_librenmssettings" template_name = "netbox_librenms_plugin/settings.html" def get(self, request): @@ -39,6 +44,10 @@ def get(self, request): def post(self, request): """Handle form submission - process the appropriate form based on form_type.""" + # Check write permission for POST actions + if error := self.require_write_permission(): + return error + # Get or create the settings object settings, created = LibreNMSSettings.objects.get_or_create() @@ -65,6 +74,30 @@ def post(self, request): if import_form.is_valid(): import_form.save() + # Also update current user's preferences to match new defaults + try: + save_user_pref( + request, + "plugins.netbox_librenms_plugin.use_sysname", + import_form.cleaned_data.get("use_sysname_default", False), + ) + save_user_pref( + request, + "plugins.netbox_librenms_plugin.strip_domain", + import_form.cleaned_data.get("strip_domain_default", False), + ) + except (TypeError, ValueError) as e: + logger.warning( + "Failed to update user preferences due to invalid value: %s (user: %s)", + e, + request.user, + ) + except Exception as e: + logger.exception( + "Unexpected error while updating user preferences for user %s: %s", + request.user, + e, + ) messages.success( request, "Import settings updated successfully.", @@ -89,7 +122,7 @@ def post(self, request): ) -class TestLibreNMSConnectionView(View): +class TestLibreNMSConnectionView(LibreNMSPermissionMixin, View): """ HTMX view to test LibreNMS server connection. Returns HTML fragment instead of JSON for HTMX compatibility. @@ -115,9 +148,9 @@ def post(self, request): system_info = api_client.test_connection() if system_info and not system_info.get("error"): - version = system_info.get("local_ver", "Unknown") - database = system_info.get("database_ver", "Unknown") - php_version = system_info.get("php_ver", "Unknown") + version = escape(system_info.get("local_ver", "Unknown")) + database = escape(system_info.get("database_ver", "Unknown")) + php_version = escape(system_info.get("php_ver", "Unknown")) return HttpResponse( f'
' @@ -129,7 +162,7 @@ def post(self, request): f"
" ) elif system_info and system_info.get("error"): - error_msg = system_info.get("message", "Unknown error occurred") + error_msg = escape(system_info.get("message", "Unknown error occurred")) return HttpResponse( f'
' f'' @@ -149,13 +182,13 @@ def post(self, request): return HttpResponse( f'
' f'' - f"Configuration error:
{str(e)}" + f"Configuration error:
{escape(str(e))}" f"
" ) except Exception as e: return HttpResponse( f'
' f'' - f"Connection failed:
{str(e)}" + f"Connection failed:
{escape(str(e))}" f"
" ) diff --git a/netbox_librenms_plugin/views/status_check.py b/netbox_librenms_plugin/views/status_check.py index ffbf6d74f4..bce8fe963d 100644 --- a/netbox_librenms_plugin/views/status_check.py +++ b/netbox_librenms_plugin/views/status_check.py @@ -12,12 +12,12 @@ ) from netbox_librenms_plugin.tables.device_status import DeviceStatusTable from netbox_librenms_plugin.tables.VM_status import VMStatusTable -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin logger = logging.getLogger(__name__) -class DeviceStatusListView(LibreNMSAPIMixin, generic.ObjectListView): +class DeviceStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView): """ Check the status of NetBox devices in LibreNMS. Shows NetBox devices with their LibreNMS status. @@ -71,7 +71,7 @@ def get_queryset(self, request): return Device.objects.none() -class VMStatusListView(LibreNMSAPIMixin, generic.ObjectListView): +class VMStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView): """ Check the status of virtual machines in NetBox against LibreNMS """ @@ -85,6 +85,7 @@ class VMStatusListView(LibreNMSAPIMixin, generic.ObjectListView): title = "Virtual Machine LibreNMS Status" def get_queryset(self, request): + """Return VMs annotated with their LibreNMS status.""" if self.request.GET: queryset = VirtualMachine.objects.select_related("cluster", "site") diff --git a/netbox_librenms_plugin/views/sync/cables.py b/netbox_librenms_plugin/views/sync/cables.py index 2b9ec4b9b3..0e52f3b017 100644 --- a/netbox_librenms_plugin/views/sync/cables.py +++ b/netbox_librenms_plugin/views/sync/cables.py @@ -7,13 +7,21 @@ from django.urls import reverse from django.views import View -from netbox_librenms_plugin.views.mixins import CacheMixin +from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin -class SyncCablesView(CacheMixin, View): +class SyncCablesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): """Create NetBox cables using cached LibreNMS link data.""" + required_object_permissions = { + "POST": [ + ("add", Cable), + ("change", Cable), + ], + } + def get_selected_interfaces(self, request, initial_device): + """Return selected interface entries from POST data.""" selected_interfaces = [] selected_data = [x for x in request.POST.getlist("select") if x] @@ -27,27 +35,33 @@ def get_selected_interfaces(self, request, initial_device): return selected_interfaces def get_cached_links_data(self, request, obj): + """Return cached LibreNMS link data for the given object.""" cached_data = cache.get(self.get_cache_key(obj, "links")) if not cached_data: return None return cached_data.get("links", []) def create_cable(self, local_interface, remote_interface, request): + """Create a cable between local and remote interfaces.""" try: Cable.objects.create( a_terminations=[local_interface], b_terminations=[remote_interface], status="connected", ) + return True except Exception as exc: # pragma: no cover - protects UX messages.error(request, f"Failed to create cable: {str(exc)}") + return False def check_existing_cable(self, local_interface, remote_interface): + """Return True if a cable already exists for either interface.""" return Cable.objects.filter( Q(terminations__termination_id=local_interface.pk) | Q(terminations__termination_id=remote_interface.pk) ).exists() def validate_prerequisites(self, cached_links, selected_interfaces): + """Validate that cached data and selections are present before sync.""" if not cached_links: messages.error( self.request, @@ -62,6 +76,7 @@ def validate_prerequisites(self, cached_links, selected_interfaces): return True def process_single_interface(self, interface, cached_links): + """Process cable creation for a single interface from cached link data.""" try: link_data = next(link for link in cached_links if link["local_port"] == interface["interface"]) return self.handle_cable_creation(link_data, interface) @@ -69,6 +84,7 @@ def process_single_interface(self, interface, cached_links): return {"status": "invalid"} 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", @@ -78,6 +94,7 @@ def verify_cable_creation_requirements(self, link_data): return all(link_data.get(field) for field in required_fields) def handle_cable_creation(self, link_data, interface): + """Create a cable from link data and return the operation result.""" if not self.verify_cable_creation_requirements(link_data): return {"status": "invalid", "interface": interface["interface"]} @@ -88,13 +105,15 @@ def handle_cable_creation(self, link_data, interface): if self.check_existing_cable(local_interface, remote_interface): return {"status": "duplicate", "interface": interface["interface"]} - self.create_cable(local_interface, remote_interface, self.request) - return {"status": "valid", "interface": interface["interface"]} + if self.create_cable(local_interface, remote_interface, self.request): + return {"status": "valid", "interface": interface["interface"]} + return {"status": "invalid", "interface": interface["interface"]} # pragma: no cover except Interface.DoesNotExist: 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.""" results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []} with transaction.atomic(): @@ -105,6 +124,11 @@ def process_interface_sync(self, selected_interfaces, cached_links): return results def post(self, request, pk): + """Sync selected cable connections from LibreNMS into NetBox.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + initial_device = get_object_or_404(Device, pk=pk) selected_interfaces = self.get_selected_interfaces(request, initial_device) cached_links = self.get_cached_links_data(request, initial_device) @@ -122,6 +146,7 @@ def post(self, request, pk): ) def display_sync_results(self, request, results): + """Display flash messages summarizing the cable sync results.""" if results["missing_remote"]: messages.error( request, diff --git a/netbox_librenms_plugin/views/sync/device_fields.py b/netbox_librenms_plugin/views/sync/device_fields.py index c424f0392b..952afdefa9 100644 --- a/netbox_librenms_plugin/views/sync/device_fields.py +++ b/netbox_librenms_plugin/views/sync/device_fields.py @@ -1,16 +1,27 @@ 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.shortcuts import get_object_or_404, redirect from django.views import View from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin -class UpdateDeviceSerialView(LibreNMSAPIMixin, View): +class UpdateDeviceSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Update NetBox device serial number from LibreNMS.""" + required_object_permissions = { + "POST": [("change", Device)], + } + def post(self, request, pk): + """Sync the device serial number from LibreNMS.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + device = get_object_or_404(Device, pk=pk) self.librenms_id = self.librenms_api.get_librenms_id(device) @@ -32,7 +43,14 @@ def post(self, request, pk): old_serial = device.serial device.serial = serial - device.save() + try: + device.full_clean() + device.save() + except (ValidationError, IntegrityError) as e: + device.serial = old_serial + error_msg = e.message_dict if hasattr(e, "message_dict") else str(e) + messages.error(request, f"Failed to update serial to '{serial}': {error_msg}") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) if old_serial: messages.success( @@ -45,10 +63,19 @@ def post(self, request, pk): return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) -class UpdateDeviceTypeView(LibreNMSAPIMixin, View): +class UpdateDeviceTypeView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Update NetBox DeviceType using LibreNMS hardware metadata.""" + required_object_permissions = { + "POST": [("change", Device)], + } + def post(self, request, pk): + """Sync the device type from LibreNMS hardware info.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + device = get_object_or_404(Device, pk=pk) self.librenms_id = self.librenms_api.get_librenms_id(device) @@ -80,7 +107,14 @@ def post(self, request, pk): device_type = match_result["device_type"] old_device_type = device.device_type device.device_type = device_type - device.save() + try: + device.full_clean() + device.save() + except (ValidationError, IntegrityError) as e: + device.device_type = old_device_type + error_msg = e.message_dict if hasattr(e, "message_dict") else str(e) + messages.error(request, f"Failed to update device type to '{device_type}': {error_msg}") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) messages.success( request, @@ -90,10 +124,19 @@ def post(self, request, pk): return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) -class UpdateDevicePlatformView(LibreNMSAPIMixin, View): +class UpdateDevicePlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Update NetBox Platform based on LibreNMS OS info.""" + required_object_permissions = { + "POST": [("change", Device)], + } + def post(self, request, pk): + """Sync the device platform from LibreNMS OS name.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + device = get_object_or_404(Device, pk=pk) self.librenms_id = self.librenms_api.get_librenms_id(device) @@ -128,7 +171,14 @@ def post(self, request, pk): old_platform = device.platform device.platform = platform - device.save() + 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 update platform to '{platform}': {error_msg}") + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) if old_platform: messages.success( @@ -141,10 +191,22 @@ def post(self, request, pk): return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) -class CreateAndAssignPlatformView(LibreNMSAPIMixin, View): +class CreateAndAssignPlatformView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Create a new Platform and assign it to the device.""" + required_object_permissions = { + "POST": [ + ("change", Device), + ("add", Platform), + ], + } + def post(self, request, pk): + """Create a new platform and assign it to the device.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + device = get_object_or_404(Device, pk=pk) platform_name = request.POST.get("platform_name") @@ -168,13 +230,28 @@ def post(self, request, pk): except Manufacturer.DoesNotExist: pass - platform = Platform.objects.create( - name=platform_name, - manufacturer=manufacturer, - ) + try: + platform = Platform.objects.create( + name=platform_name, + manufacturer=manufacturer, + ) + except IntegrityError: + messages.error( + request, + f"Platform '{platform_name}' could not be created (slug collision). Try a different name.", + ) + return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) + old_platform = device.platform device.platform = platform - device.save() + 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, @@ -184,10 +261,19 @@ def post(self, request, pk): return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk) -class AssignVCSerialView(LibreNMSAPIMixin, View): +class AssignVCSerialView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View): """Assign serial numbers to each virtual chassis member.""" + required_object_permissions = { + "POST": [("change", Device)], + } + def post(self, request, pk): + """Sync serial numbers to virtual chassis member devices.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + device = get_object_or_404(Device, pk=pk) if not device.virtual_chassis: @@ -214,8 +300,17 @@ def post(self, request, pk): counter += 1 continue + old_serial = member.serial member.serial = serial - member.save() + try: + member.full_clean() + member.save() + except (ValidationError, IntegrityError) as e: + member.serial = old_serial + error_msg = e.message_dict if hasattr(e, "message_dict") else str(e) + errors.append(f"Failed to set serial on {member.name}: {error_msg}") + counter += 1 + continue assignments_made += 1 diff --git a/netbox_librenms_plugin/views/sync/devices.py b/netbox_librenms_plugin/views/sync/devices.py index fcd199e5c2..da0f9af5b0 100644 --- a/netbox_librenms_plugin/views/sync/devices.py +++ b/netbox_librenms_plugin/views/sync/devices.py @@ -4,53 +4,62 @@ from django.views import View from virtualization.models import VirtualMachine -from netbox_librenms_plugin.forms import AddToLIbreSNMPV2, AddToLIbreSNMPV3 -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3 +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin -class AddDeviceToLibreNMSView(LibreNMSAPIMixin, View): +class AddDeviceToLibreNMSView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """Add a NetBox device or VM to LibreNMS via the API.""" def get_form_class(self): + """Return the appropriate SNMP form class based on the SNMP version.""" snmp_version = self.request.POST.get("snmp_version") if not snmp_version: - snmp_version = self.request.POST.get("v2-snmp_version") or self.request.POST.get("v3-snmp_version") + snmp_version = self.request.POST.get("v1v2-snmp_version") or self.request.POST.get("v3-snmp_version") - if snmp_version == "v2c": - return AddToLIbreSNMPV2 + if snmp_version in ("v1", "v2c"): + return AddToLIbreSNMPV1V2 return AddToLIbreSNMPV3 def get_object(self, object_id): + """Return the Device or VirtualMachine for the given ID.""" try: return Device.objects.get(pk=object_id) except Device.DoesNotExist: return VirtualMachine.objects.get(pk=object_id) def post(self, request, object_id): + """Add a device to LibreNMS using the submitted SNMP form.""" + # Check write permission before adding device to LibreNMS + if error := self.require_write_permission(): + return error + self.object = self.get_object(object_id) form_class = self.get_form_class() - snmp_version = ( - request.POST.get("snmp_version") - or request.POST.get("v2-snmp_version") - or request.POST.get("v3-snmp_version") - ) - prefix = "v2" if snmp_version == "v2c" else "v3" + snmp_version = request.POST.get("v1v2-snmp_version") or request.POST.get("v3-snmp_version") + prefix = "v1v2" if snmp_version in ("v1", "v2c") else "v3" form = form_class(request.POST, prefix=prefix) if form.is_valid(): - return self.form_valid(form) + # Inject snmp_version from toggle into cleaned_data for v1/v2c forms + if snmp_version in ("v1", "v2c"): + form.cleaned_data["snmp_version"] = snmp_version + return self.form_valid(form, snmp_version=snmp_version) for field, errors in form.errors.items(): for error in errors: messages.error(request, f"{field}: {error}") return redirect(self.object.get_absolute_url()) - def form_valid(self, form): + def form_valid(self, form, snmp_version=None): + """Submit the validated SNMP form data to the LibreNMS API.""" data = form.cleaned_data + # Use the snmp_version from toggle/form for v1/v2c, or from form data for v3 + version = snmp_version or data.get("snmp_version") device_data = { "hostname": data.get("hostname"), - "snmp_version": data.get("snmp_version"), + "snmp_version": version, "force_add": data.get("force_add", False), } @@ -66,7 +75,7 @@ def form_valid(self, form): except (ValueError, TypeError): pass - if device_data["snmp_version"] == "v2c": + if device_data["snmp_version"] in ("v1", "v2c"): device_data["community"] = data.get("community") elif device_data["snmp_version"] == "v3": device_data.update( @@ -92,10 +101,15 @@ def form_valid(self, form): return redirect(self.object.get_absolute_url()) -class UpdateDeviceLocationView(LibreNMSAPIMixin, View): +class UpdateDeviceLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View): """Update the LibreNMS site/location based on the NetBox site.""" def post(self, request, pk): + """Sync the device location to LibreNMS from the NetBox site.""" + # Check write permission before updating location in LibreNMS + if error := self.require_write_permission(): + return error + device = get_object_or_404(Device, pk=pk) self.librenms_id = self.librenms_api.get_librenms_id(device) diff --git a/netbox_librenms_plugin/views/sync/interfaces.py b/netbox_librenms_plugin/views/sync/interfaces.py index e9bbd0c22b..a19629cccf 100644 --- a/netbox_librenms_plugin/views/sync/interfaces.py +++ b/netbox_librenms_plugin/views/sync/interfaces.py @@ -10,21 +10,47 @@ 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.views.mixins import CacheMixin +from netbox_librenms_plugin.views.mixins import ( + CacheMixin, + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, + VlanAssignmentMixin, +) -class SyncInterfacesView(CacheMixin, View): +class SyncInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, VlanAssignmentMixin, CacheMixin, View): """Sync selected interfaces from LibreNMS into NetBox.""" + def get_required_permissions_for_object_type(self, object_type): + """Return the required permissions based on object type.""" + if object_type == "device": + return [("add", Interface), ("change", Interface)] + elif object_type == "virtualmachine": + return [("add", VMInterface), ("change", VMInterface)] + else: + raise Http404(f"Invalid object type: {object_type}") + def post(self, request, object_type, object_id): + """Sync selected interfaces from LibreNMS into NetBox.""" + # Set permissions dynamically based on object type + self.required_object_permissions = { + "POST": self.get_required_permissions_for_object_type(object_type), + } + + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + url_name = ( "dcim:device_librenms_sync" if object_type == "device" else "plugins:netbox_librenms_plugin:vm_librenms_sync" ) obj = self.get_object(object_type, object_id) + self.object = obj # Store for use in sync methods interface_name_field = get_interface_name_field(request) + self.interface_name_field = interface_name_field selected_interfaces = self.get_selected_interfaces(request, interface_name_field) exclude_columns = request.POST.getlist("exclude_columns") @@ -41,6 +67,11 @@ def post(self, request, object_type, object_id): + f"?tab=interfaces&interface_name_field={interface_name_field}" ) + # Prepare VLAN lookup maps if VLAN sync is enabled + vlan_groups = self.get_vlan_groups_for_device(obj) + lookup_maps = self._build_vlan_lookup_maps(vlan_groups) + self._lookup_maps = lookup_maps + self.sync_selected_interfaces(obj, selected_interfaces, ports_data, exclude_columns, interface_name_field) messages.success(request, "Selected interfaces synced successfully.") @@ -49,6 +80,7 @@ def post(self, request, object_type, object_id): ) def get_object(self, object_type, object_id): + """Return the Device or VirtualMachine for the given type and ID.""" if object_type == "device": return get_object_or_404(Device, pk=object_id) if object_type == "virtualmachine": @@ -56,6 +88,7 @@ def get_object(self, object_type, object_id): raise Http404("Invalid object type.") def get_selected_interfaces(self, request, interface_name_field): + """Return the list of selected interface names from POST data.""" selected_interfaces = request.POST.getlist("select") if not selected_interfaces: messages.error(request, "No interfaces selected for synchronization.") @@ -63,6 +96,7 @@ def get_selected_interfaces(self, request, interface_name_field): return selected_interfaces def get_cached_ports_data(self, request, obj): + """Return cached LibreNMS port data for the given object.""" cached_data = cache.get(self.get_cache_key(obj, "ports")) if not cached_data: messages.warning( @@ -80,6 +114,7 @@ def sync_selected_interfaces( exclude_columns, interface_name_field, ): + """Create or update NetBox interfaces from LibreNMS port data.""" with transaction.atomic(): for port in ports_data: port_name = port.get(interface_name_field) @@ -88,6 +123,7 @@ def sync_selected_interfaces( self.sync_interface(obj, port, exclude_columns, interface_name_field) def sync_interface(self, obj, librenms_interface, exclude_columns, interface_name_field): + """Create or update a single NetBox interface from LibreNMS data.""" interface_name = librenms_interface.get(interface_name_field) if isinstance(obj, Device): @@ -95,7 +131,17 @@ def sync_interface(self, obj, librenms_interface, exclude_columns, interface_nam selected_device_id = self.request.POST.get(device_selection_key) if selected_device_id: - target_device = Device.objects.get(id=selected_device_id) + try: + target_device = Device.objects.get(id=selected_device_id) + # Validate the target is the current device or a VC member + if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: + valid_ids = set(obj.virtual_chassis.members.values_list("id", flat=True)) + if target_device.id not in valid_ids: + target_device = obj + elif target_device.id != obj.id: + target_device = obj + except (Device.DoesNotExist, ValueError, TypeError): + target_device = obj else: target_device = obj @@ -120,18 +166,28 @@ def sync_interface(self, obj, librenms_interface, exclude_columns, interface_nam if "enabled" not in exclude_columns: interface.enabled = ( True - if librenms_interface["ifAdminStatus"] is None + if librenms_interface.get("ifAdminStatus") is None else ( librenms_interface["ifAdminStatus"].lower() == "up" if isinstance(librenms_interface["ifAdminStatus"], str) else bool(librenms_interface["ifAdminStatus"]) ) ) - interface.save() + + # 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): - speed = convert_speed_to_kbps(librenms_interface["ifSpeed"]) - mappings = InterfaceTypeMapping.objects.filter(librenms_type=librenms_interface["ifType"]) + """Return the NetBox interface type mapped from LibreNMS type and speed.""" + speed = convert_speed_to_kbps(librenms_interface.get("ifSpeed")) + mappings = InterfaceTypeMapping.objects.filter(librenms_type=librenms_interface.get("ifType")) if speed is not None: speed_mapping = mappings.filter(librenms_speed__lte=speed).order_by("-librenms_speed").first() @@ -142,6 +198,7 @@ def get_netbox_interface_type(self, librenms_interface): return mapping.netbox_type if mapping else "other" def handle_mac_address(self, interface, ifPhysAddress): + """Assign or create the MAC address for the given interface.""" if ifPhysAddress: existing_mac = interface.mac_addresses.filter(mac_address=ifPhysAddress).first() if existing_mac: @@ -160,6 +217,7 @@ def update_interface_attributes( exclude_columns, interface_name_field, ): + """Update interface fields from LibreNMS data, respecting excluded columns.""" is_device_interface = isinstance(interface, Interface) LIBRENMS_TO_NETBOX_MAPPING = { @@ -195,11 +253,65 @@ def update_interface_attributes( interface.save() + def _sync_interface_vlans(self, interface, librenms_port, interface_name): + """ + Sync VLAN assignments from LibreNMS to NetBox interface. + Sets mode, untagged_vlan, and tagged_vlans based on LibreNMS data. + + Args: + interface: NetBox Interface or VMInterface object + librenms_port: Port data dict from LibreNMS with VLAN info + interface_name: Original interface name for form field lookup + """ + # Get per-VLAN group selections from form (safely handle special chars in name) + safe_name = interface_name.replace("/", "_").replace(":", "_") + + # Build VLAN data from port + vlan_data = { + "untagged_vlan": librenms_port.get("untagged_vlan"), + "tagged_vlans": librenms_port.get("tagged_vlans", []), + } -class DeleteNetBoxInterfacesView(CacheMixin, View): + # Build per-VLAN group map from POST data + vlan_group_map = {} + all_vids = [] + if vlan_data["untagged_vlan"]: + all_vids.append(str(vlan_data["untagged_vlan"])) + for vid in vlan_data.get("tagged_vlans", []): + all_vids.append(str(vid)) + + for vid in all_vids: + group_id = self.request.POST.get(f"vlan_group_{safe_name}_{vid}", "") + if group_id: + vlan_group_map[vid] = group_id + + # Use mixin method to update interface VLAN assignments + self._update_interface_vlan_assignment(interface, vlan_data, vlan_group_map, self._lookup_maps) + + +class DeleteNetBoxInterfacesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): """Delete interfaces that exist only in NetBox.""" + def get_required_permissions_for_object_type(self, object_type): + """Return the required permissions based on object type.""" + if object_type == "device": + return [("delete", Interface)] + elif object_type == "virtualmachine": + return [("delete", VMInterface)] + else: + raise Http404(f"Invalid object type: {object_type}") + def post(self, request, object_type, object_id): + """Delete selected NetBox-only interfaces not present in LibreNMS.""" + # Set permissions dynamically based on object type + self.required_object_permissions = { + "POST": self.get_required_permissions_for_object_type(object_type), + } + + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions_json("POST"): + return error + if object_type == "device": obj = get_object_or_404(Device, pk=object_id) elif object_type == "virtualmachine": @@ -214,13 +326,16 @@ def post(self, request, object_type, object_id): deleted_count = 0 errors = [] + interface_name = None try: with transaction.atomic(): for interface_id in interface_ids: + interface_name = None try: if object_type == "device": interface = Interface.objects.get(id=interface_id) + interface_name = interface.name if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: valid_device_ids = [member.id for member in obj.virtual_chassis.members.all()] if interface.device_id not in valid_device_ids: @@ -235,11 +350,11 @@ def post(self, request, object_type, object_id): continue else: interface = VMInterface.objects.get(id=interface_id) + interface_name = interface.name if interface.virtual_machine_id != obj.id: errors.append(f"Interface {interface.name} does not belong to this virtual machine") continue - interface_name = interface.name interface.delete() deleted_count += 1 @@ -247,7 +362,7 @@ def post(self, request, object_type, object_id): errors.append(f"Interface with ID {interface_id} not found") continue except Exception as exc: # pragma: no cover - defensive - errors.append(f"Error deleting interface {interface_name}: {str(exc)}") + errors.append(f"Error deleting interface {interface_name or interface_id}: {str(exc)}") continue except Exception as exc: # pragma: no cover diff --git a/netbox_librenms_plugin/views/sync/ip_addresses.py b/netbox_librenms_plugin/views/sync/ip_addresses.py index f51e58c6aa..474a3a446c 100644 --- a/netbox_librenms_plugin/views/sync/ip_addresses.py +++ b/netbox_librenms_plugin/views/sync/ip_addresses.py @@ -9,16 +9,25 @@ from ipam.models import VRF, IPAddress from virtualization.models import VirtualMachine, VMInterface -from netbox_librenms_plugin.views.mixins import CacheMixin +from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin -class SyncIPAddressesView(CacheMixin, View): +class SyncIPAddressesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): """Synchronize IP addresses from LibreNMS cache into NetBox.""" + required_object_permissions = { + "POST": [ + ("add", IPAddress), + ("change", IPAddress), + ], + } + def get_selected_ips(self, request): + """Return selected IP addresses from POST data.""" return [x for x in request.POST.getlist("select") if x] def get_vrf_selection(self, request, ip_address): + """Return the VRF selected for a given IP address, or None.""" vrf_id = request.POST.get(f"vrf_{ip_address}") if vrf_id: @@ -30,12 +39,14 @@ def get_vrf_selection(self, request, ip_address): return None def get_cached_ip_data(self, request, obj): + """Return cached LibreNMS IP address data for the given object.""" cached_data = cache.get(self.get_cache_key(obj, "ip_addresses")) if not cached_data: return None return cached_data.get("ip_addresses", []) def get_object(self, object_type, pk): + """Return the Device or VirtualMachine instance for the given type and pk.""" if object_type == "device": return get_object_or_404(Device, pk=pk) if object_type == "virtualmachine": @@ -43,6 +54,7 @@ def get_object(self, object_type, pk): raise Http404("Invalid object type.") def get_ip_tab_url(self, obj): + """Return the URL for the IP addresses sync tab.""" if isinstance(obj, Device): url_name = "plugins:netbox_librenms_plugin:device_librenms_sync" else: @@ -50,6 +62,11 @@ def get_ip_tab_url(self, obj): return f"{reverse(url_name, args=[obj.pk])}?tab=ipaddresses" def post(self, request, object_type, pk): + """Sync selected IP addresses from LibreNMS into NetBox.""" + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + obj = self.get_object(object_type, pk) selected_ips = self.get_selected_ips(request) @@ -69,6 +86,7 @@ def post(self, request, object_type, pk): return redirect(self.get_ip_tab_url(obj)) def process_ip_sync(self, request, selected_ips, cached_ips, obj, object_type): + """Create or update IP addresses in NetBox from cached LibreNMS data.""" results = {"created": [], "updated": [], "unchanged": [], "failed": []} with transaction.atomic(): @@ -113,6 +131,7 @@ def process_ip_sync(self, request, selected_ips, cached_ips, obj, object_type): return results def display_sync_results(self, request, results): + """Display flash messages summarizing the IP sync results.""" if results["created"]: messages.success(request, f"Created IP addresses: {', '.join(results['created'])}") if results["updated"]: diff --git a/netbox_librenms_plugin/views/sync/locations.py b/netbox_librenms_plugin/views/sync/locations.py index c1e7e06895..915ed61941 100644 --- a/netbox_librenms_plugin/views/sync/locations.py +++ b/netbox_librenms_plugin/views/sync/locations.py @@ -8,10 +8,10 @@ from netbox_librenms_plugin.filtersets import SiteLocationFilterSet from netbox_librenms_plugin.tables.locations import SiteLocationSyncTable -from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin +from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin -class SyncSiteLocationView(LibreNMSAPIMixin, SingleTableView): +class SyncSiteLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, SingleTableView): """Synchronize NetBox Sites with LibreNMS locations.""" table_class = SiteLocationSyncTable @@ -22,17 +22,20 @@ class SyncSiteLocationView(LibreNMSAPIMixin, SingleTableView): SyncData = namedtuple("SyncData", ["netbox_site", "librenms_location", "is_synced"]) def get_table(self, *args, **kwargs): + """Return the configured sync table.""" table = super().get_table(*args, **kwargs) table.configure(self.request) return table def get_context_data(self, **kwargs): + """Return context with filter form for site-location sync.""" context = super().get_context_data(**kwargs) queryset = self.get_queryset() context["filter_form"] = self.filterset(self.request.GET, queryset=queryset).form return context def get_queryset(self): + """Return sync data pairing NetBox sites with LibreNMS locations.""" netbox_sites = Site.objects.all() success, librenms_locations = self.get_librenms_locations() if not success or not isinstance(librenms_locations, list): @@ -50,9 +53,11 @@ def get_queryset(self): return sync_data def get_librenms_locations(self): + """Fetch all locations from LibreNMS.""" return self.librenms_api.get_locations() def create_sync_data(self, site, librenms_locations): + """Create a SyncData tuple pairing a site with its LibreNMS location.""" matched_location = self.match_site_with_location(site, librenms_locations) if matched_location: is_synced = self.check_coordinates_match( @@ -65,12 +70,14 @@ def create_sync_data(self, site, librenms_locations): return self.SyncData(site, None, False) def match_site_with_location(self, site, librenms_locations): + """Return the LibreNMS location matching the given site, or None.""" for location in librenms_locations: if location["location"].lower() == site.name.lower() or location["location"].lower() == site.slug.lower(): return location return None def check_coordinates_match(self, site_lat, site_lng, librenms_lat, librenms_lng): + """Return True if site and LibreNMS coordinates match within tolerance.""" if None in (site_lat, site_lng, librenms_lat, librenms_lng): return False lat_match = abs(float(site_lat) - float(librenms_lat)) < self.COORDINATE_TOLERANCE @@ -78,6 +85,11 @@ def check_coordinates_match(self, site_lat, site_lng, librenms_lat, librenms_lng return lat_match and lng_match def post(self, request): + """Handle create or update of a LibreNMS location from a NetBox site.""" + # Check write permission before modifying LibreNMS locations + if error := self.require_write_permission(): + return error + action = request.POST.get("action") pk = request.POST.get("pk") if not pk: @@ -98,12 +110,14 @@ def post(self, request): return redirect("plugins:netbox_librenms_plugin:site_location_sync") def get_site_by_pk(self, pk): + """Return the Site for the given pk, or None if not found.""" try: return Site.objects.get(pk=pk) except ObjectDoesNotExist: return None def create_librenms_location(self, request, site): + """Create a new location in LibreNMS from the given site.""" location_data = self.build_location_data(site) success, message = self.librenms_api.add_location(location_data) if success: @@ -116,6 +130,7 @@ def create_librenms_location(self, request, site): return redirect("plugins:netbox_librenms_plugin:site_location_sync") def update_librenms_location(self, request, site): + """Update an existing LibreNMS location with the site coordinates.""" if site.latitude is None or site.longitude is None: messages.warning( request, @@ -145,6 +160,7 @@ def update_librenms_location(self, request, site): return redirect("plugins:netbox_librenms_plugin:site_location_sync") def build_location_data(self, site, include_name=True): + """Build a location data dict from the given site.""" data = {"lat": str(site.latitude), "lng": str(site.longitude)} if include_name: data["location"] = site.name diff --git a/netbox_librenms_plugin/views/sync/vlans.py b/netbox_librenms_plugin/views/sync/vlans.py new file mode 100644 index 0000000000..c5fae91530 --- /dev/null +++ b/netbox_librenms_plugin/views/sync/vlans.py @@ -0,0 +1,161 @@ +from dcim.models import Device +from django.contrib import messages +from django.core.cache import cache +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.views import View +from ipam.models import VLAN, VLANGroup + +from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin + + +class SyncVLANsView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, CacheMixin, View): + """ + Handle POST requests to create/update VLANs in NetBox from LibreNMS data. + """ + + required_object_permissions = { + "POST": [ + ("add", VLAN), + ("change", VLAN), + ], + } + + def post(self, request, object_type: str, object_id: int): + """ + Process sync request. + + Expected POST data: + - action: 'create_vlans' + - select: List of VLAN IDs to create + - vlan_group_{vid}: Per-row VLAN group selection + """ + # Check both plugin write and NetBox object permissions + if error := self.require_all_permissions("POST"): + return error + + obj = self.get_object(object_type, object_id) + action = request.POST.get("action", "") + + if action == "create_vlans": + return self._handle_create_vlans(request, obj, object_type, object_id) + else: + messages.error(request, "Invalid action specified.") + return self._redirect(object_type, object_id) + + def get_object(self, object_type: str, object_id: int): + """Get the target object (Device or VM).""" + if object_type == "device": + return get_object_or_404(Device, pk=object_id) + raise Http404("Invalid object type.") + + def _redirect(self, object_type: str, object_id: int): + """Redirect back to sync page with VLAN tab active.""" + url_name = ( + "dcim:device_librenms_sync" + if object_type == "device" + else "plugins:netbox_librenms_plugin:vm_librenms_sync" + ) + return redirect(reverse(url_name, kwargs={"pk": object_id}) + "?tab=vlans") + + def _handle_create_vlans(self, request, obj, object_type, object_id): + """ + Handle creating selected VLANs in NetBox. + + Reads per-row VLAN group selections from form fields named 'vlan_group_{vid}'. + """ + selected_vlans = request.POST.getlist("select") + + if not selected_vlans: + messages.error(request, "No VLANs selected for creation.") + return self._redirect(object_type, object_id) + + # Get cached VLAN data + cached_vlans = cache.get(self.get_cache_key(obj, "vlans")) + if not cached_vlans: + messages.error(request, "No cached VLAN data. Please refresh VLANs first.") + return self._redirect(object_type, object_id) + + # Build lookup of LibreNMS VLANs by VID + librenms_vlans = {str(v["vlan_vlan"]): v for v in cached_vlans} + + created_count = 0 + updated_count = 0 + skipped_count = 0 + + with transaction.atomic(): + for vid_str in selected_vlans: + try: + vid = int(vid_str) + except ValueError: + continue + + vlan_data = librenms_vlans.get(vid_str) + if not vlan_data: + continue + + # Get per-row VLAN group selection + group_id_str = request.POST.get(f"vlan_group_{vid}", "") + row_vlan_group = None + if group_id_str: + try: + row_vlan_group = VLANGroup.objects.get(pk=int(group_id_str)) + except (ValueError, VLANGroup.DoesNotExist): + pass # Fall back to global VLAN (no group) + + librenms_name = vlan_data.get("vlan_name", f"VLAN {vid}") + + if row_vlan_group: + # Grouped VLAN: match by VID (unique constraint within group) + vlan, created = VLAN.objects.get_or_create( + vid=vid, + group=row_vlan_group, + defaults={ + "name": librenms_name, + "status": "active", + }, + ) + if created: + created_count += 1 + elif vlan.name != librenms_name: + vlan.name = librenms_name + vlan.save() + updated_count += 1 + else: + skipped_count += 1 + else: + # Global VLAN: match by VID only (unique constraint with group=NULL) + vlan, created = VLAN.objects.get_or_create( + vid=vid, + group=None, + defaults={ + "name": librenms_name, + "status": "active", + }, + ) + if created: + created_count += 1 + elif vlan.name != librenms_name: + vlan.name = librenms_name + vlan.save() + updated_count += 1 + else: + skipped_count += 1 + + # Build summary message + parts = [] + if created_count > 0: + parts.append(f"{created_count} created") + if updated_count > 0: + parts.append(f"{updated_count} updated") + if skipped_count > 0: + parts.append(f"{skipped_count} unchanged") + + if parts: + messages.success(request, f"VLANs synced: {', '.join(parts)}.") + else: + messages.warning(request, "No VLANs were created or updated.") + + return self._redirect(object_type, object_id) diff --git a/pyproject.toml b/pyproject.toml index 596ae21b5a..83c70fc848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ include-package-data = true [tool.setuptools.packages.find] include = ["netbox_librenms_plugin*"] -exclude = ["site*"] +exclude = ["site*", "netbox_librenms_plugin.tests*"] [tool.setuptools.package-data] netbox_librenms_plugin = ["templates/**"]