first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
"""
Module for initializing views for the NetBox LibreNMS plugin.
All imports below are intentional re-exports consumed by urls.py and
other modules. The F401 suppressions prevent linters from flagging
them as unused within this file.
"""
from .base.cables_view import BaseCableTableView, SingleCableVerifyView # noqa: F401
from .base.interfaces_view import BaseInterfaceTableView # noqa: F401
from .base.ip_addresses_view import BaseIPAddressTableView, SingleIPAddressVerifyView # noqa: F401
from .base.librenms_sync_view import BaseLibreNMSSyncView # noqa: F401
from .base.vlan_table_view import BaseVLANTableView # noqa: F401
from .imports import ( # noqa: F401
BulkImportConfirmView,
BulkImportDevicesView,
DeviceClusterUpdateView,
DeviceConflictActionView,
DeviceRackUpdateView,
DeviceRoleUpdateView,
DeviceValidationDetailsView,
DeviceVCDetailsView,
LibreNMSImportView,
SaveUserPrefView,
)
from .mapping_views import ( # noqa: F401
InterfaceTypeMappingBulkDeleteView,
InterfaceTypeMappingBulkImportView,
InterfaceTypeMappingChangeLogView,
InterfaceTypeMappingCreateView,
InterfaceTypeMappingDeleteView,
InterfaceTypeMappingEditView,
InterfaceTypeMappingListView,
InterfaceTypeMappingView,
)
from .object_sync import ( # noqa: F401
DeviceCableTableView,
DeviceInterfaceTableView,
DeviceIPAddressTableView,
DeviceLibreNMSSyncView,
DeviceVLANTableView,
SaveVlanGroupOverridesView,
SingleInterfaceVerifyView,
SingleVlanGroupVerifyView,
VerifyVlanSyncGroupView,
VMInterfaceTableView,
VMIPAddressTableView,
VMLibreNMSSyncView,
)
from .settings_views import LibreNMSSettingsView, TestLibreNMSConnectionView # noqa: F401
from .status_check import DeviceStatusListView, VMStatusListView # noqa: F401
from .sync.cables import SyncCablesView # noqa: F401
from .sync.device_fields import ( # noqa: F401
AssignVCSerialView,
ConvertLegacyLibreNMSIdView,
CreateAndAssignPlatformView,
RemoveServerMappingView,
UpdateDeviceNameView,
UpdateDevicePlatformView,
UpdateDeviceSerialView,
UpdateDeviceTypeView,
)
from .sync.devices import AddDeviceToLibreNMSView, UpdateDeviceLocationView # noqa: F401
from .sync.interfaces import DeleteNetBoxInterfacesView, SyncInterfacesView # noqa: F401
from .sync.ip_addresses import SyncIPAddressesView # noqa: F401
from .sync.locations import SyncSiteLocationView # noqa: F401
from .sync.vlans import SyncVLANsView # noqa: F401

View File

@@ -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",
]

View File

@@ -0,0 +1,576 @@
import json
from dcim.models import Device, Interface
from django.contrib import messages
from django.core.cache import cache
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Q
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.utils.html import escape
from django.views import View
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_sync_device,
get_virtual_chassis_member,
)
from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
def _librenms_id_q(server_key: str, value) -> Q:
"""
Return a combined Q matching JSON-field and legacy bare-int librenms_id.
Matches both integer and string representations to handle any stored format.
"""
if isinstance(value, bool):
return Q(pk__isnull=True) & Q(pk__isnull=False) # match nothing
q = Q(**{f"custom_field_data__librenms_id__{server_key}": value}) | Q(custom_field_data__librenms_id=value)
try:
int_val = int(value)
str_val = str(int_val)
if int_val != value: # value was a string; also add the integer variant
q |= Q(**{f"custom_field_data__librenms_id__{server_key}": int_val})
q |= Q(custom_field_data__librenms_id=int_val)
if str_val != value: # value was an integer; also add the string variant
q |= Q(**{f"custom_field_data__librenms_id__{server_key}": str_val})
q |= Q(custom_field_data__librenms_id=str_val)
except (TypeError, ValueError):
pass
return q
class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing cable information from LibreNMS.
"""
model = None # To be defined in subclasses
partial_template_name = "netbox_librenms_plugin/_cable_sync_content.html"
def get_object(self, pk):
"""Retrieve the object (Device or VirtualMachine)."""
return get_object_or_404(self.model, pk=pk)
def get_ip_address(self, obj):
"""Get the primary IP address for the object."""
if obj.primary_ip:
return str(obj.primary_ip.address.ip)
return None
def get_ports_data(self, obj):
"""Get ports data without affecting cache"""
server_key = self.librenms_api.server_key
cached_data = cache.get(self.get_cache_key(obj, "ports", server_key))
if cached_data:
return cached_data
success, data = self.librenms_api.get_ports(self.librenms_id)
if not success:
return {"ports": []}
return data
def get_links_data(self, obj):
"""Fetch links data from LibreNMS for the device and add local port names."""
self.librenms_id = self.librenms_api.get_librenms_id(obj)
success, data = self.librenms_api.get_device_links(self.librenms_id)
if not success or "error" in data:
return None
interface_name_field = get_interface_name_field(getattr(self, "request", None))
ports_data = self.get_ports_data(obj)
local_ports_map = {}
for port in ports_data.get("ports", []):
raw_port_id = port.get("port_id")
if raw_port_id is None:
continue
port_id = str(raw_port_id)
port_name = port.get(interface_name_field)
if port_name is None:
continue
local_ports_map[port_id] = port_name
links = data.get("links", [])
links_data = []
for link in links:
local_port_name = local_ports_map.get(str(link.get("local_port_id")))
links_data.append(
{
"local_port": local_port_name,
"local_port_id": link.get("local_port_id"),
"remote_port": link.get("remote_port"),
"remote_device": link.get("remote_hostname"),
"remote_port_id": link.get("remote_port_id"),
"remote_device_id": link.get("remote_device_id"),
}
)
return links_data
def get_device_by_id_or_name(self, remote_device_id, hostname, server_key=None):
"""Try to find device in NetBox first by librenms_id custom field, then by name"""
if server_key is None:
server_key = self.librenms_api.server_key
# First try matching by LibreNMS ID
if remote_device_id is not None:
try:
device = Device.objects.get(_librenms_id_q(server_key, remote_device_id))
return device, True, None
except Device.DoesNotExist:
pass
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same LibreNMS ID: {remote_device_id}.",
)
# Fall back to name matching if no device found by ID
try:
device = Device.objects.get(name=hostname)
return device, True, None
except Device.DoesNotExist:
# Try without domain name
simple_hostname = hostname.split(".")[0]
try:
device = Device.objects.get(name=simple_hostname)
return device, True, None
except Device.DoesNotExist:
return None, False, None
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same name: {hostname}.",
)
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same name: {hostname}.",
)
def enrich_local_port(self, link, obj, server_key=None):
"""Add local port URL if interface exists in NetBox"""
if local_port := link.get("local_port"):
interface = None
local_port_id = link.get("local_port_id")
if server_key is None:
server_key = self.librenms_api.server_key
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
chassis_member = get_virtual_chassis_member(obj, local_port)
if chassis_member:
# First try to find interface by librenms_id
if local_port_id:
interface = chassis_member.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first()
# Only if librenms_id match fails, try matching by name
if not interface:
interface = chassis_member.interfaces.filter(name=local_port).first()
else:
# First try to find interface by librenms_id
if local_port_id:
interface = obj.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first()
# Only if librenms_id match fails, try matching by name
if not interface:
interface = obj.interfaces.filter(name=local_port).first()
if interface:
link["local_port_url"] = reverse("dcim:interface", args=[interface.pk])
link["netbox_local_interface_id"] = interface.pk
def enrich_remote_port(self, link, device, server_key=None):
"""Add remote port URL if device and interface exist in NetBox"""
if remote_port := link.get("remote_port"):
netbox_remote_interface = None
librenms_remote_port_id = link.get("remote_port_id")
if server_key is None:
server_key = self.librenms_api.server_key
# Handle virtual chassis case
if hasattr(device, "virtual_chassis") and device.virtual_chassis:
# Get the appropriate chassis member based on the port name
chassis_member = get_virtual_chassis_member(device, remote_port)
if chassis_member:
# First try to find interface by librenms_id
if librenms_remote_port_id:
netbox_remote_interface = chassis_member.interfaces.filter(
_librenms_id_q(server_key, librenms_remote_port_id)
).first()
# If not found by librenms_id, fall back to name matching on the correct chassis member
if not netbox_remote_interface:
netbox_remote_interface = chassis_member.interfaces.filter(name=remote_port).first()
else:
# Non-virtual chassis case
# First try to find interface by librenms_id
if librenms_remote_port_id:
netbox_remote_interface = device.interfaces.filter(
_librenms_id_q(server_key, librenms_remote_port_id)
).first()
# If not found by librenms_id, fall back to name matching
if not netbox_remote_interface:
netbox_remote_interface = device.interfaces.filter(name=remote_port).first()
if netbox_remote_interface:
link["remote_port_url"] = reverse("dcim:interface", args=[netbox_remote_interface.pk])
link["netbox_remote_interface_id"] = netbox_remote_interface.pk
link["remote_port_name"] = netbox_remote_interface.name
return link
def check_cable_status(self, link):
"""Check cable status and add cable URL if cable exists in NetBox"""
local_interface_id = link.get("netbox_local_interface_id")
remote_interface_id = link.get("netbox_remote_interface_id")
# Default state
link["can_create_cable"] = False
if local_interface_id and remote_interface_id:
local_interface = Interface.objects.get(pk=local_interface_id)
remote_interface = Interface.objects.get(pk=remote_interface_id)
existing_cable = local_interface.cable or remote_interface.cable
if existing_cable:
link.update(
{
"cable_status": "Cable Found",
"cable_url": reverse("dcim:cable", args=[existing_cable.pk]),
}
)
else:
link.update({"cable_status": "No Cable", "can_create_cable": True})
else:
link["cable_status"] = (
"Both Interfaces Not Found in Netbox"
if not (local_interface_id or remote_interface_id)
else "Local Interface Not Found in Netbox"
if not local_interface_id
else "Remote Interface Not Found in Netbox"
)
return link
def process_remote_device(self, link, remote_hostname, remote_device_id, server_key=None):
"""Process remote device data and add remote device URL if device exists in NetBox"""
device, found, error_message = self.get_device_by_id_or_name(
remote_device_id, remote_hostname, server_key=server_key
)
if found:
link.update(
{
"remote_device_url": reverse("dcim:device", args=[device.pk]),
"netbox_remote_device_id": device.pk,
}
)
return self.enrich_remote_port(link, device, server_key=server_key)
link.update(
{
"remote_port_name": link["remote_port"],
"cable_status": error_message if error_message else "Device Not Found in NetBox",
"can_create_cable": False,
}
)
return link
def enrich_links_data(self, links_data, obj, server_key=None):
"""Enrich links data with local and remote port URLs and cable status."""
for link in links_data:
self.enrich_local_port(link, obj, server_key=server_key)
link["device_id"] = obj.id
if remote_hostname := link.get("remote_device"):
link = self.process_remote_device(
link, remote_hostname, link.get("remote_device_id"), server_key=server_key
)
if link.get("netbox_remote_device_id"):
link = self.check_cable_status(link)
return links_data
def get_table(self, data, obj):
"""Get the table instance for the view."""
table = super().get_table(data, obj)
server_key = self.librenms_api.server_key
table.htmx_url = f"{self.request.path}?tab=cables" + (f"&server_key={server_key}" if server_key else "")
return table
def _prepare_context(self, request, obj, fetch_fresh=False):
"""Helper method to prepare the context data for cable sync views."""
table = None
cache_expiry = None
server_key = self.librenms_api.server_key
# For VC devices, cache under the sync device's key so SingleCableVerifyView reads the same entry.
cache_device = get_librenms_sync_device(obj, server_key=server_key) or obj
if fetch_fresh:
# Always fetch new data when requested
links_data = self.get_links_data(obj)
if not links_data:
return None
else:
# Try to use cached data
cached_links_data = cache.get(self.get_cache_key(cache_device, "links", server_key))
if cached_links_data:
links_data = cached_links_data.get("links", [])
else:
return None
if not fetch_fresh:
# Strip derived fields so re-enrichment starts from raw link data;
# without this, stale IDs/URLs persist when NetBox objects are
# deleted and cause DoesNotExist in check_cable_status().
_raw_keys = {
"local_port",
"local_port_id",
"remote_port",
"remote_device",
"remote_port_id",
"remote_device_id",
}
links_data = [{k: v for k, v in link.items() if k in _raw_keys} for link in links_data]
# Enrich data in both cases to ensure current NetBox state
links_data = self.enrich_links_data(links_data, obj, server_key=server_key)
# Cache after enrichment so verify/sync views read current NetBox state
cache_key = self.get_cache_key(cache_device, "links", server_key)
if fetch_fresh:
cache.set(
cache_key,
{"links": links_data},
timeout=self.librenms_api.cache_timeout,
)
else:
# Write enriched data back, preserving original TTL
remaining_ttl = cache.ttl(cache_key)
if remaining_ttl and remaining_ttl > 0:
cache.set(cache_key, {"links": links_data}, timeout=remaining_ttl)
# Calculate cache expiry
cache_ttl = cache.ttl(cache_key)
if cache_ttl is not None and cache_ttl > 0:
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl)
# Generate the table
table = self.get_table(links_data, obj)
table.configure(request)
# Prepare and return the context
return {
"table": table,
"object": obj,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
def get_context_data(self, request, obj):
"""Get the context data for the cable sync view."""
context = self._prepare_context(request, obj, fetch_fresh=False)
if context is None:
# No data found; return context with empty table
context = {"table": None, "object": obj, "cache_expiry": None, "server_key": self.librenms_api.server_key}
return context
def post(self, request, pk):
"""Handle POST request for cable sync view."""
obj = self.get_object(pk)
context = self._prepare_context(request, obj, fetch_fresh=True)
if context is None:
messages.error(request, "No links found in LibreNMS")
return render(
request,
self.partial_template_name,
{
"cable_sync": {
"object": obj,
"table": None,
"cache_expiry": None,
"server_key": self.librenms_api.server_key,
}
},
)
messages.success(request, "Cable data refreshed successfully.")
return render(
request,
self.partial_template_name,
{"cable_sync": context},
)
class SingleCableVerifyView(BaseCableTableView):
"""
View to verify a single cable link between two devices.
"""
def post(self, request):
data = json.loads(request.body)
selected_device_id = data.get("device_id")
local_port_id = data.get("local_port_id")
# Read server_key from POST so we use the exact server the user was viewing
server_key = data.get("server_key")
if not server_key:
server_key = self.librenms_api.server_key
formatted_row = {
"local_port": "",
"remote_port": "",
"remote_device": "",
"cable_status": "Missing Ports",
"actions": "",
}
if selected_device_id:
selected_device = get_object_or_404(Device, pk=selected_device_id)
# Use the same sync-device resolution as the GET path so the cache
# key matches what _prepare_context wrote. When the VC has no
# resolvable sync device, return an empty row rather than crashing.
if selected_device.virtual_chassis:
primary_device = get_librenms_sync_device(selected_device, server_key=server_key)
if primary_device is None:
return JsonResponse({"status": "success", "formatted_row": formatted_row})
else:
primary_device = selected_device
cached_links = cache.get(self.get_cache_key(primary_device, "links", server_key))
if cached_links:
link_data = next(
(
link
for link in cached_links.get("links", [])
if str(link.get("local_port_id", "")) == str(local_port_id)
),
None,
)
if link_data:
# Strip derived fields from cached data to avoid stale
# IDs/URLs when NetBox objects are deleted after caching.
_raw_keys = {
"local_port",
"local_port_id",
"remote_port",
"remote_device",
"remote_port_id",
"remote_device_id",
}
link_data = {k: v for k, v in link_data.items() if k in _raw_keys}
# Re-enrich remote side from current NetBox state
remote_hostname = link_data.get("remote_device", "")
if remote_hostname:
link_data = self.process_remote_device(
link_data, remote_hostname, link_data.get("remote_device_id"), server_key=server_key
)
local_port = link_data.get("local_port", "")
formatted_row["local_port"] = local_port
# First try to find interface by librenms_id (handle VC members)
_sk = server_key
interface = None
lookup_device = selected_device
if local_port and hasattr(selected_device, "virtual_chassis") and selected_device.virtual_chassis:
chassis_member = get_virtual_chassis_member(selected_device, local_port)
if chassis_member:
lookup_device = chassis_member
if local_port_id:
interface = lookup_device.interfaces.filter(_librenms_id_q(_sk, local_port_id)).first()
# If not found by librenms_id, try matching by name
if not interface and local_port:
interface = lookup_device.interfaces.filter(name=local_port).first()
if interface:
link_data["netbox_local_interface_id"] = interface.pk
# Check cable status if remote side was resolved
if link_data.get("netbox_remote_device_id"):
link_data = self.check_cable_status(link_data)
# Escape LibreNMS-sourced labels to prevent XSS
safe_local_port = escape(local_port)
remote_port_name = link_data.get("remote_port_name", link_data.get("remote_port", ""))
safe_remote_port = escape(remote_port_name)
remote_device_name = link_data.get("remote_device", "")
safe_remote_device = escape(remote_device_name)
safe_cable_status = escape(link_data.get("cable_status", "Missing Ports"))
formatted_row["cable_status"] = safe_cable_status
formatted_row["local_port"] = (
f'<a href="{reverse("dcim:interface", args=[interface.pk])}">{safe_local_port}</a>'
)
formatted_row["remote_port"] = (
f'<a href="{link_data["remote_port_url"]}">{safe_remote_port}</a>'
if link_data.get("remote_port_url")
else safe_remote_port
)
formatted_row["remote_device"] = (
f'<a href="{link_data["remote_device_url"]}">{safe_remote_device}</a>'
if link_data.get("remote_device_url")
else safe_remote_device
)
if link_data.get("cable_url"):
formatted_row["cable_status"] = (
f'<a href="{link_data["cable_url"]}">{safe_cable_status}</a>'
)
if link_data.get("can_create_cable"):
csrf_token = get_token(request)
server_key_input = (
f'<input type="hidden" name="server_key" value="{escape(str(server_key))}">'
if server_key
else ""
)
formatted_row["actions"] = f"""
<form method="post" action="{reverse("plugins:netbox_librenms_plugin:sync_device_cables", args=[selected_device.id])}">
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
<input type="hidden" name="select" value="{escape(str(local_port_id))}">
{server_key_input}
<button type="submit" class="btn btn-sm btn-primary">Sync Cable</button>
</form>
"""
else:
formatted_row["local_port"] = escape(local_port)
# Keep remote port name visible, add URL if available
remote_port_name = link_data.get("remote_port_name", link_data.get("remote_port", ""))
safe_remote_port = escape(remote_port_name)
formatted_row["remote_port"] = (
f'<a href="{link_data["remote_port_url"]}">{safe_remote_port}</a>'
if link_data.get("remote_port_url")
else safe_remote_port
)
# Keep remote device name visible, add URL if available
remote_device_name = link_data.get("remote_device", "")
safe_remote_device = escape(remote_device_name)
formatted_row["remote_device"] = (
f'<a href="{link_data["remote_device_url"]}">{safe_remote_device}</a>'
if link_data.get("remote_device_url")
else safe_remote_device
)
# First check if remote device exists in NetBox
if remote_device_name and not link_data.get("remote_device_url"):
formatted_row["cable_status"] = "Device Not Found in NetBox"
# Then check interface status
elif link_data.get("remote_device_url") and link_data.get("remote_port_url"):
formatted_row["cable_status"] = "Local Interface Not Found in NetBox"
else:
formatted_row["cable_status"] = "Missing Interface"
formatted_row["actions"] = ""
return JsonResponse({"status": "success", "formatted_row": formatted_row})

View File

@@ -0,0 +1,374 @@
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views import View
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_virtual_chassis_member,
)
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
VlanAssignmentMixin,
)
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
partial_template_name = "netbox_librenms_plugin/_interface_sync_content.html"
interface_name_field = None
def get_object(self, pk):
"""Retrieve the object (Device or VirtualMachine)."""
return get_object_or_404(self.model, pk=pk)
def get_ip_address(self, obj):
"""Get the primary IP address for the object."""
if obj.primary_ip:
return str(obj.primary_ip.address.ip)
return None
def get_interfaces(self, obj):
"""
Get interfaces related to the object.
Should be implemented in subclasses.
"""
raise NotImplementedError
def get_redirect_url(self, obj):
"""
Get the redirect URL for the object.
Should be implemented in subclasses.
"""
raise NotImplementedError
def get_select_related_field(self, obj):
"""Determine the appropriate select_related field based on object type"""
if self.model.__name__.lower() == "virtualmachine":
return "virtual_machine"
return "device"
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()")
def post(self, request, pk):
"""Handle POST request to fetch and cache LibreNMS interface data for an object."""
obj = self.get_object(pk)
interface_name_field = get_interface_name_field(request)
# Get librenms_id at the start
self.librenms_id = self.librenms_api.get_librenms_id(obj)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS.")
return redirect(self.get_redirect_url(obj))
success, librenms_data = self.librenms_api.get_ports(self.librenms_id)
if not success:
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
_server_key = self.librenms_api.server_key
# Store data in cache (keyed by server to avoid cross-server collisions)
cache.set(
self.get_cache_key(obj, "ports", _server_key),
librenms_data,
timeout=self.librenms_api.cache_timeout,
)
last_fetched = timezone.now()
cache.set(
self.get_last_fetched_key(obj, "ports", _server_key),
last_fetched,
timeout=self.librenms_api.cache_timeout,
)
messages.success(request, "Interface data refreshed successfully.")
context = self.get_context_data(request, obj, interface_name_field, _server_key)
context = {"interface_sync": context}
context["interface_name_field"] = interface_name_field
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, server_key=None):
"""Get the context data for the interface sync view."""
ports_data = []
table = None
netbox_only_interfaces = []
if interface_name_field is None:
interface_name_field = get_interface_name_field(request)
if server_key is None:
server_key = getattr(self.librenms_api, "server_key", None)
cached_data = cache.get(self.get_cache_key(obj, "ports", server_key))
last_fetched = cache.get(self.get_last_fetched_key(obj, "ports", server_key))
# 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, server_key)) or {}
if cached_data:
ports_data = cached_data.get("ports", [])
# Pre-fetch all interfaces for all potential chassis members
interfaces_by_device = {}
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
for member in obj.virtual_chassis.members.all():
interfaces_by_device[member.id] = {
interface.name: interface
for interface in self.get_interfaces(member).select_related(self.get_select_related_field(obj))
}
else:
interfaces_by_device[obj.id] = {
interface.name: interface
for interface in self.get_interfaces(obj).select_related(self.get_select_related_field(obj))
}
for port in ports_data:
port["enabled"] = (
True
if port.get("ifAdminStatus") is None
else (
port["ifAdminStatus"].lower() == "up"
if isinstance(port["ifAdminStatus"], str)
else bool(port["ifAdminStatus"])
)
)
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
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.get(interface_name_field))
port["exists_in_netbox"] = bool(netbox_interface)
port["netbox_interface"] = netbox_interface
if port.get("ifAlias") in (port.get("ifDescr"), port.get("ifName")):
port["ifAlias"] = ""
# 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)
librenms_interface_names = {
port.get(interface_name_field) for port in ports_data if port.get(interface_name_field)
}
netbox_only_interfaces = []
for device_id, device_interfaces in interfaces_by_device.items():
for interface_name, interface in device_interfaces.items():
if interface_name not in librenms_interface_names:
# Get device name for the interface
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
device = obj.virtual_chassis.members.get(id=device_id)
device_name = device.name
else:
device_name = obj.name
netbox_only_interfaces.append(
{
"id": interface.id,
"name": interface.name,
"device_name": device_name,
"device_id": device_id,
"type": str(interface.type)
if hasattr(interface, "type") and interface.type
else "Virtual"
if hasattr(interface, "virtual_machine")
else "Unknown",
"enabled": interface.enabled,
"description": interface.description or "",
"url": interface.get_absolute_url(),
}
)
virtual_chassis_members = []
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
virtual_chassis_members = obj.virtual_chassis.members.all()
cache_ttl = cache.ttl(self.get_cache_key(obj, "ports", server_key))
cache_expiry = (
timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None and cache_ttl > 0 else None
)
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,
"server_key": server_key,
}
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

View File

@@ -0,0 +1,498 @@
import json
from dcim.models import Device
from django.contrib import messages
from django.core.cache import cache
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.views import View
from ipam.models import VRF, IPAddress
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.tables.ipaddresses import IPAddressTable
from netbox_librenms_plugin.utils import get_interface_name_field, get_librenms_device_id
from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
class BaseIPAddressTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing IP address information from LibreNMS.
"""
partial_template_name = "netbox_librenms_plugin/_ipaddress_sync_content.html"
interface_name_field = None
def get_object(self, pk):
return get_object_or_404(self.model, pk=pk)
def get_ip_addresses(self, obj):
"""Fetch IP address data from LibreNMS for the given object."""
self.librenms_id = self.librenms_api.get_librenms_id(obj)
return self.librenms_api.get_device_ips(self.librenms_id)
def enrich_ip_data(self, ip_data, obj, interface_name_field):
"""
Enrich IP data with NetBox information in a more efficient manner.
This optimized implementation:
1. Caches port data to reduce API calls
2. Pre-loads all relevant device data
3. Uses dictionary lookups instead of repeated iterations
"""
# Prefetch all necessary data
prefetched_data = self._prefetch_netbox_data(obj)
port_data_cache = {} # Cache for LibreNMS port data to minimize API calls
enriched_data = []
# Process each IP address from LibreNMS
for ip_entry in ip_data:
# Skip invalid entries that are not dictionaries
if not isinstance(ip_entry, dict):
continue
# Skip entries missing required fields
if "port_id" not in ip_entry:
continue
# Get or fetch port data (with caching)
port_info = self._get_port_info(ip_entry["port_id"], port_data_cache, interface_name_field)
# Create enriched IP structure with base data
enriched_ip = self._create_base_ip_entry(ip_entry, obj, prefetched_data["vrfs"])
# Get LibreNMS interface name if available
librenms_interface_name = None
if port_info:
librenms_interface_name = port_info.get(interface_name_field)
enriched_ip["interface_name"] = librenms_interface_name
# IP with mask is already calculated in _create_base_ip_entry
ip_with_mask = enriched_ip["ip_with_mask"]
ip_address = prefetched_data["ip_addresses_map"].get(ip_with_mask)
if ip_address:
# Process existing IP
self._enrich_existing_ip(
enriched_ip,
ip_address,
ip_entry["port_id"],
librenms_interface_name,
prefetched_data,
)
else:
# New IP that doesn't exist in NetBox
enriched_ip["exists"] = False
enriched_ip["status"] = "sync"
# Add interface information (regardless of IP status)
self._add_interface_info_to_ip(
enriched_ip,
ip_entry["port_id"],
librenms_interface_name,
prefetched_data,
)
enriched_data.append(enriched_ip)
return enriched_data
def _prefetch_netbox_data(self, obj):
"""Prefetch all necessary NetBox data to minimize database queries"""
# Get all interfaces for the device
all_interfaces = list(obj.interfaces.all())
# Create maps for efficient lookups
server_key = self.librenms_api.server_key
interfaces_by_librenms_id = {}
for interface in all_interfaces:
lib_id = get_librenms_device_id(interface, server_key, auto_save=False)
if lib_id is not None:
interfaces_by_librenms_id[str(lib_id)] = interface
interfaces_by_name = {interface.name: interface for interface in all_interfaces}
# Get all IP addresses
ip_addresses_map = {
str(ip.address): ip for ip in IPAddress.objects.select_related("assigned_object_type", "vrf")
}
# Get all VRFs
vrfs = list(VRF.objects.all())
return {
"interfaces_by_librenms_id": interfaces_by_librenms_id,
"interfaces_by_name": interfaces_by_name,
"all_interfaces": all_interfaces,
"device": obj,
"ip_addresses_map": ip_addresses_map,
"vrfs": vrfs,
}
def _get_port_info(self, port_id, port_data_cache, interface_name_field):
"""Get port info from LibreNMS with caching to minimize API calls"""
if port_id not in port_data_cache:
success, port_data = self.librenms_api.get_port_by_id(port_id)
if success and "port" in port_data and port_data["port"]:
port_data_cache[port_id] = port_data["port"][0]
else:
port_data_cache[port_id] = None
return port_data_cache[port_id]
def _create_base_ip_entry(self, ip_entry, obj, vrfs):
"""Create the base data structure for an IP entry"""
# Determine if this is an IPv4 or IPv6 address and create unified fields
if "ip_address" in ip_entry and "prefix_length" in ip_entry:
# Use unified format directly if available
ip_address = ip_entry["ip_address"]
prefix_length = ip_entry["prefix_length"]
else:
# Legacy format handling
if "ipv6_compressed" in ip_entry:
ip_address = ip_entry["ipv6_compressed"]
prefix_length = ip_entry["ipv6_prefixlen"]
elif "ipv4_address" in ip_entry:
ip_address = ip_entry["ipv4_address"]
prefix_length = ip_entry["ipv4_prefixlen"]
else:
raise KeyError("No valid IP address format found in LibreNMS data")
ip_with_mask = f"{ip_address}/{prefix_length}"
return {
"ip_address": ip_address,
"prefix_length": prefix_length,
"ip_with_mask": ip_with_mask,
"port_id": ip_entry["port_id"],
"device": obj.name,
"device_url": obj.get_absolute_url(),
"vrf_id": None,
"vrfs": vrfs,
}
def _enrich_existing_ip(self, enriched_ip, ip_address, port_id, librenms_interface_name, prefetched_data):
"""Add information for IP addresses that exist in NetBox"""
enriched_ip["ip_url"] = ip_address.get_absolute_url()
enriched_ip["exists"] = True
# Add VRF info if available
if ip_address.vrf:
enriched_ip["vrf_id"] = ip_address.vrf.pk
enriched_ip["vrf"] = ip_address.vrf.name
# Set initial status to update (will change to matched if criteria met)
enriched_ip["status"] = "update"
# Only proceed if IP is assigned to an object
if not ip_address.assigned_object:
return
assigned_interface = ip_address.assigned_object
# Check if interface matches by LibreNMS ID
if str(port_id) in prefetched_data["interfaces_by_librenms_id"]:
interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)]
if assigned_interface == interface:
enriched_ip["status"] = "matched"
return
# Check if interface matches by name
if librenms_interface_name and assigned_interface.name == librenms_interface_name:
enriched_ip["status"] = "matched"
# Add interface information
enriched_ip["interface_name"] = assigned_interface.name
enriched_ip["interface_url"] = assigned_interface.get_absolute_url()
def _add_interface_info_to_ip(self, enriched_ip, port_id, librenms_interface_name, prefetched_data):
"""Add interface information to the IP entry regardless of IP status"""
# First try to match by LibreNMS ID (highest priority)
if str(port_id) in prefetched_data["interfaces_by_librenms_id"]:
interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)]
enriched_ip["interface_name"] = interface.name
enriched_ip["interface_url"] = interface.get_absolute_url()
return
# Then try to match by interface name
if librenms_interface_name and librenms_interface_name in prefetched_data["interfaces_by_name"]:
interface = prefetched_data["interfaces_by_name"][librenms_interface_name]
# Don't overwrite the interface name from LibreNMS but do add the URL
enriched_ip["interface_url"] = interface.get_absolute_url()
def get_table(self, data, obj, request):
"""Get the table instance for the view."""
table = IPAddressTable(data)
server_key = self.librenms_api.server_key
table.htmx_url = f"{request.path}?tab=ipaddresses" + (f"&server_key={server_key}" if server_key else "")
return table
def _prepare_context(self, request, obj, interface_name_field, fetch_fresh=False):
"""Helper method to prepare the context data for IP address sync views."""
table = None
cache_expiry = None
server_key = self.librenms_api.server_key
if interface_name_field is None:
interface_name_field = get_interface_name_field(request)
if fetch_fresh:
success, ip_data = self.get_ip_addresses(obj)
else:
cached_ip_data = cache.get(self.get_cache_key(obj, "ip_addresses", server_key))
if cached_ip_data:
ip_data = cached_ip_data.get("ip_addresses", [])
else:
return None
# Enrich data in both cases to ensure current NetBox state
ip_data = self.enrich_ip_data(ip_data, obj, interface_name_field)
if fetch_fresh:
# Cache the fresh data after enrichment
cache.set(
self.get_cache_key(obj, "ip_addresses", server_key),
{"ip_addresses": ip_data},
timeout=self.librenms_api.cache_timeout,
)
# Calculate cache expiry
cache_ttl = cache.ttl(self.get_cache_key(obj, "ip_addresses", server_key))
if cache_ttl is not None and cache_ttl > 0:
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl)
# Generate the table
table = self.get_table(ip_data, obj, request)
table.configure(request)
# Prepare and return the context
return {
"table": table,
"object": obj,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
def get_context_data(self, request, obj):
"""Get the context data for the IP address sync view."""
interface_name_field = get_interface_name_field(request)
context = self._prepare_context(request, obj, interface_name_field, fetch_fresh=False)
if context is None:
# No data found; return context with empty table
context = {"table": None, "object": obj, "cache_expiry": None, "server_key": self.librenms_api.server_key}
return context
def post(self, request, pk):
"""Handle POST request for IP address sync view."""
obj = self.get_object(pk)
interface_name_field = get_interface_name_field(request)
context = self._prepare_context(request, obj, interface_name_field, fetch_fresh=True)
if context is None:
messages.error(request, "No IP addresses found in LibreNMS")
return render(
request,
self.partial_template_name,
{
"ip_sync": {
"object": obj,
"table": None,
"cache_expiry": None,
"server_key": self.librenms_api.server_key,
}
},
)
messages.success(request, "IP address data refreshed successfully.")
return render(
request,
self.partial_template_name,
{"ip_sync": context},
)
class SingleIPAddressVerifyView(LibreNMSPermissionMixin, CacheMixin, View):
"""
View for verifying single IP address data with different VRF.
"""
def _get_object(self, object_id, object_type=None):
"""
Retrieve the object (Device or VirtualMachine) based on ID and optional type.
If type is not provided, tries to determine it by checking both Device and VM models.
"""
if object_type == "device":
return get_object_or_404(Device, pk=object_id)
elif object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=object_id)
else:
# Try to find object without knowing its type
obj = Device.objects.filter(pk=object_id).first()
if obj:
return obj
obj = VirtualMachine.objects.filter(pk=object_id).first()
if obj:
return obj
raise Http404(f"Object with ID {object_id} not found in Device or VirtualMachine models")
def _parse_ip_address(self, ip_address):
"""
Parse IP address string into address and prefix length.
Works with both IPv4 and IPv6 addresses.
"""
ip_address_parts = ip_address.split("/")
address_no_mask = ip_address_parts[0].strip()
if len(ip_address_parts) > 1:
try:
prefix_len = int(ip_address_parts[1])
return address_no_mask, prefix_len
except ValueError:
raise ValueError(f"Invalid prefix length: {ip_address_parts[1]}")
else:
raise ValueError("Prefix length is missing from the IP address")
def _find_in_cache(self, cached_data, address, prefix_len):
"""Find IP address in cache data using unified fields only."""
if not cached_data:
return None, None, None
for ip_entry in cached_data.get("ip_addresses", []):
if ip_entry["ip_address"] == address and str(ip_entry["prefix_length"]) == str(prefix_len):
return (ip_entry, ip_entry.get("vrf_id"), ip_entry.get("port_id"))
return None, None, None
def _find_existing_ip(self, address_no_mask, prefix_len, vrf_id=None):
"""
Find existing IP address in NetBox, optionally with specific VRF.
"""
ip_with_mask = f"{address_no_mask}/{prefix_len}"
# Check if IP exists in any VRF
existing_ip = IPAddress.objects.filter(address=ip_with_mask).first()
if not existing_ip:
return False, False, None
# IP exists in some VRF, check if it exists in the specified VRF
if vrf_id is not None:
existing_in_vrf = IPAddress.objects.filter(address=ip_with_mask, vrf__id=vrf_id).exists()
else:
# Check for global VRF (None)
existing_in_vrf = IPAddress.objects.filter(address=ip_with_mask, vrf__isnull=True).exists()
return True, existing_in_vrf, existing_ip.get_absolute_url()
def _determine_status(self, exists_any_vrf, exists_specific_vrf, original_vrf_id, vrf_id):
"""
Determine the status of an IP address based on existence and VRF.
"""
if exists_any_vrf:
# IP exists in NetBox
if exists_specific_vrf:
return "matched"
else:
return "update"
else:
# IP doesn't exist in NetBox, check if restoring to original VRF
if original_vrf_id is not None and original_vrf_id == vrf_id:
return "matched"
else:
return "sync"
def post(self, request):
"""
POST request to return json response with formatted IP address status.
"""
try:
try:
data = json.loads(request.body)
except json.JSONDecodeError as e:
return JsonResponse({"status": "error", "message": f"Invalid JSON: {e}"}, status=400)
ip_address = data.get("ip_address")
vrf_id = data.get("vrf_id")
object_id = data.get("device_id")
object_type = data.get("object_type")
server_key = data.get("server_key") or "default"
if not ip_address:
return JsonResponse({"status": "error", "message": "No IP address provided"}, status=400)
if not object_id:
return JsonResponse({"status": "error", "message": "No object ID provided"}, status=400)
# Get the object (Device or VirtualMachine)
try:
obj = self._get_object(object_id, object_type)
except Http404 as e:
return JsonResponse({"status": "error", "message": str(e)}, status=404)
# Parse IP address
try:
address_no_mask, prefix_len = self._parse_ip_address(ip_address)
except ValueError as e:
return JsonResponse({"status": "error", "message": str(e)}, status=400)
cache_key = self.get_cache_key(obj, "ip_addresses", server_key)
cached_data = cache.get(cache_key)
# Basic record with default values
updated_record = {
"ip_address": address_no_mask,
"prefix_length": prefix_len,
"ip_with_mask": f"{address_no_mask}/{prefix_len}",
"device": obj.name,
"device_url": obj.get_absolute_url(),
"vrf_id": vrf_id,
"exists": False,
"status": "sync",
}
# Try to find the IP in cache data
cache_entry, original_vrf_id, original_port_id = self._find_in_cache(
cached_data, address_no_mask, prefix_len
)
# Update record with cache data if found
if cache_entry:
# Update with all fields except vrf_id and status
for key, value in cache_entry.items():
if key not in ["vrf_id", "status"]:
updated_record[key] = value
# If no interface found in cache, use first device interface
if original_port_id is None:
interface = obj.interfaces.first()
if interface:
updated_record["interface_name"] = interface.name
updated_record["interface_url"] = interface.get_absolute_url()
# Check if IP exists in NetBox
exists_any_vrf, exists_specific_vrf, ip_url = self._find_existing_ip(address_no_mask, prefix_len, vrf_id)
if exists_any_vrf:
updated_record["exists"] = True
updated_record["ip_url"] = ip_url
# Determine status based on existence and VRF
updated_record["status"] = self._determine_status(
exists_any_vrf, exists_specific_vrf, original_vrf_id, vrf_id
)
# Render status HTML
table = IPAddressTable(data=[])
status_html = table.render_status(updated_record["status"], updated_record)
return JsonResponse(
{
"status": "success",
"ip_address": ip_address,
"formatted_row": {"status": status_html},
}
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -0,0 +1,547 @@
import re
from django.conf import settings as django_settings
from django.shortcuts import get_object_or_404, render
from netbox.views import generic
from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
from netbox_librenms_plugin.import_utils import _determine_device_name
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_device_id,
get_librenms_sync_device,
match_librenms_hardware_to_device_type,
resolve_naming_preferences,
)
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
class BaseLibreNMSSyncView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Base view for LibreNMS sync information.
"""
queryset = None # Will be set in subclasses
model = None # Will be set in subclasses
tab = None # Will be set in subclasses
template_name = "netbox_librenms_plugin/librenms_sync_base.html"
def get(self, request, pk, context=None):
"""Handle GET request for the LibreNMS sync view."""
obj = get_object_or_404(self.model, pk=pk)
# For Virtual Chassis members, always delegate to get_librenms_sync_device() so
# self._librenms_lookup_device and self.librenms_id are consistent with the
# helper-based VC status computed in get_context_data(). A legacy bare-int mapping
# on the viewed member must not shadow an explicit per-server mapping on another
# member — get_librenms_sync_device() applies the full priority order.
librenms_lookup_device = obj
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
sync_device = get_librenms_sync_device(obj, server_key=self.librenms_api.server_key)
if sync_device:
librenms_lookup_device = sync_device
# Store for use in get_context_data (badge generation needs the same object)
self._librenms_lookup_device = librenms_lookup_device
# Get librenms_id using the determined lookup device
self.librenms_id = self.librenms_api.get_librenms_id(librenms_lookup_device)
context = self.get_context_data(request, obj)
return render(request, self.template_name, context)
def get_context_data(self, request, obj):
"""Get the context data for the LibreNMS sync view."""
# Get context from parent classes (including LibreNMSAPIMixin)
context = super().get_context_data()
# Add our specific context
context.update(
{
"object": obj,
"tab": self.tab,
"has_librenms_id": bool(self.librenms_id),
}
)
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
# Use helper function to determine the sync device
librenms_sync_device = get_librenms_sync_device(obj, server_key=self.librenms_api.server_key)
# Determine sync device status
sync_device_has_librenms_id = False
sync_device_has_primary_ip = False
if librenms_sync_device:
sync_device_has_librenms_id = (
get_librenms_device_id(librenms_sync_device, self.librenms_api.server_key, auto_save=False)
is not None
)
sync_device_has_primary_ip = bool(librenms_sync_device.primary_ip)
context.update(
{
"is_vc_member": True,
"sync_device_has_primary_ip": sync_device_has_primary_ip,
"librenms_sync_device": librenms_sync_device,
"sync_device_has_librenms_id": sync_device_has_librenms_id,
}
)
librenms_info = self.get_librenms_device_info(obj, request)
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)
module_context = self.get_module_context(request, obj)
interface_name_field = get_interface_name_field(request)
# Get platform info for display and sync
platform_info = self._get_platform_info(librenms_info, obj)
# Get manufacturers for platform creation modal
from dcim.models import Manufacturer
manufacturers = Manufacturer.objects.all().order_by("name")
# Detect legacy bare-int librenms_id format for conversion badge
_lookup_device = getattr(self, "_librenms_lookup_device", obj)
_raw_cf = _lookup_device.cf.get("librenms_id") if _lookup_device else None
librenms_id_is_legacy = (isinstance(_raw_cf, int) and not isinstance(_raw_cf, bool)) or (
isinstance(_raw_cf, str) and _raw_cf.isdigit()
)
# Determine if serial match allows legacy ID conversion.
# VMs have no serial field in NetBox; skip the gate so the Convert ID button is enabled.
_librenms_serial = librenms_info["librenms_device_details"].get("librenms_device_serial", "-")
_netbox_serial = getattr(_lookup_device, "serial", "") or ""
_lookup_is_vm = _lookup_device._meta.model_name == "virtualmachine" if _lookup_device else False
librenms_id_serial_confirmed = _lookup_is_vm or bool(
_librenms_serial and _librenms_serial != "-" and _netbox_serial and _librenms_serial == _netbox_serial
)
context.update(
{
"interface_sync": interface_context,
"cable_sync": cable_context,
"ip_sync": ip_context,
"vlan_sync": vlan_context,
"module_sync": module_context,
"v1v2form": AddToLIbreSNMPV1V2(prefix="v1v2"),
"v3form": AddToLIbreSNMPV3(prefix="v3"),
"librenms_device_id": self.librenms_id,
"found_in_librenms": librenms_info.get("found_in_librenms"),
"librenms_device_details": librenms_info.get("librenms_device_details"),
"mismatched_device": librenms_info.get("mismatched_device"),
**librenms_info["librenms_device_details"],
"interface_name_field": interface_name_field,
"platform_info": platform_info,
"vc_inventory_serials": librenms_info["librenms_device_details"].get("vc_inventory_serials", []),
"manufacturers": manufacturers,
"all_server_mappings": self._build_all_server_mappings(_lookup_device, self.librenms_api.server_key),
"librenms_id_is_legacy": librenms_id_is_legacy,
"librenms_id_serial_confirmed": librenms_id_serial_confirmed,
# Lookup device may differ from object (e.g. VC master vs member).
# Used by the Remove server mapping form to post to the correct device.
"lookup_device_pk": _lookup_device.pk if _lookup_device else obj.pk,
"lookup_device_model_name": (
_lookup_device._meta.model_name if _lookup_device else obj._meta.model_name
),
"object_model_name": obj._meta.model_name,
}
)
return context
@staticmethod
def _build_all_server_mappings(obj, active_server_key):
"""
Build a list of all LibreNMS server mappings for the given device.
Each entry describes one server<->ID mapping stored in the ``librenms_id``
custom field:
* ``server_key`` the key as stored in the CF dict.
* ``display_name`` human-readable name from PLUGINS_CONFIG, or the key.
* ``librenms_url`` base URL of that server (``None`` when not configured).
* ``device_id`` the integer device ID on that server.
* ``device_url`` direct URL to the device page on that server (or ``None``).
* ``is_configured`` True when the server key exists in current plugin config.
* ``is_active`` True when this is the currently active server.
Returns ``None`` for legacy bare-int format (no per-server info to show)
and ``None`` when the CF is absent/invalid.
"""
cf_value = obj.custom_field_data.get("librenms_id")
if not isinstance(cf_value, dict) or not cf_value:
return None
plugins_cfg = getattr(django_settings, "PLUGINS_CONFIG", {}).get("netbox_librenms_plugin", {})
servers_config = plugins_cfg.get("servers") or {}
if not isinstance(servers_config, dict):
servers_config = {}
result = []
for sk, did in cf_value.items():
# Validate device ID — accept int or digit-string, skip bool/None/junk.
if isinstance(did, bool) or did is None:
continue
if isinstance(did, str):
if not did.isdigit():
continue
did = int(did)
elif not isinstance(did, int):
continue
srv_cfg = servers_config.get(sk)
# Legacy single-server config: "default" key with no matching servers entry —
# fall back to root-level librenms_url/display_name in plugins_cfg.
if srv_cfg is None and sk == "default":
legacy_url = plugins_cfg.get("librenms_url")
if legacy_url:
srv_cfg = {
"librenms_url": legacy_url,
"display_name": plugins_cfg.get("display_name") or f"Default Server ({legacy_url})",
}
is_configured = srv_cfg is not None
# Treat malformed (non-dict) server config entries as unconfigured
if srv_cfg is not None and not isinstance(srv_cfg, dict):
srv_cfg = None
is_configured = False
librenms_url = srv_cfg.get("librenms_url") if srv_cfg else None
display_name = (srv_cfg.get("display_name") or sk) if srv_cfg else sk
device_url = f"{librenms_url}/device/device={did}/" if librenms_url else None
result.append(
{
"server_key": sk,
"display_name": display_name,
"librenms_url": librenms_url,
"device_id": did,
"device_url": device_url,
"is_configured": is_configured,
"is_active": sk == active_server_key,
}
)
# Sort: active first, then configured, then orphaned
result.sort(key=lambda e: 0 if e["is_active"] else (1 if e["is_configured"] else 2))
return result or None
def get_librenms_device_info(self, obj, request=None):
"""Get the LibreNMS device information for the given object."""
found_in_librenms = False
mismatched_device = False
librenms_device_details = {
"librenms_device_url": None,
"librenms_device_hardware": "-",
"librenms_device_serial": "-",
"librenms_device_os": "-",
"librenms_device_version": "-",
"librenms_device_features": "-",
"librenms_device_location": "-",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
}
if self.librenms_id:
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).lower() if obj.primary_ip else None
netbox_name = obj.name
# Get LibreNMS device details
librenms_sysname = device_info.get("sysName")
librenms_ip = device_info.get("ip")
# Extract new fields
hardware = device_info.get("hardware", "-")
serial = device_info.get("serial", "-")
os_name = device_info.get("os", "-")
version = device_info.get("version", "-")
features = device_info.get("features", "-")
# Try to match hardware to NetBox DeviceType
hardware_match = match_librenms_hardware_to_device_type(hardware)
# Compute resolved name using naming preferences
resolved_name = None
if request:
use_sysname, strip_domain = resolve_naming_preferences(request)
resolved_name = _determine_device_name(
device_info,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=self.librenms_id,
)
# For VC members, generate the expected VC member name
if (
resolved_name
and hasattr(obj, "virtual_chassis")
and obj.virtual_chassis is not None
and obj.vc_position is not None
):
resolved_name = _generate_vc_member_name(
resolved_name,
obj.vc_position,
serial=getattr(obj, "serial", None),
)
# Update device details regardless of match
librenms_device_details.update(
{
"librenms_device_url": f"{self.librenms_api.librenms_url}/device/device={self.librenms_id}/",
"librenms_device_hardware": hardware,
"librenms_device_serial": serial,
"librenms_device_os": os_name,
"librenms_device_version": version,
"librenms_device_features": features,
"librenms_device_location": device_info.get("location", "-"),
"librenms_device_ip": librenms_ip,
"sysName": librenms_sysname,
"resolved_name": resolved_name or librenms_sysname,
"librenms_device_hostname": device_info.get("hostname", "-"),
"librenms_device_hardware_match": hardware_match,
}
)
# For Virtual Chassis, fetch inventory
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
vc_serials = self._get_vc_inventory_serials(obj)
librenms_device_details["vc_inventory_serials"] = vc_serials
# 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,
"mismatched_device": mismatched_device,
}
def get_interface_context(self, request, obj):
"""
Get the context data for interface sync.
Subclasses should override this method.
"""
return None
def get_cable_context(self, request, obj):
"""
Get the context data for cable sync.
Subclasses should override this method if applicable.
"""
return None
def get_ip_context(self, request, obj):
"""
Get the context data for IP address sync.
Subclasses should override this method.
"""
return None
def get_vlan_context(self, request, obj):
"""
Get the context data for VLAN sync.
Subclasses should override this method.
"""
return None
def get_module_context(self, request, obj):
"""
Get the context data for module sync.
Subclasses should override this method if applicable (e.g. VMs return None).
"""
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.
Args:
obj: NetBox device object (VC member)
Returns:
list: [
{
'description': 'Chassis component description',
'serial': 'serial number',
'model': 'model name',
'assigned_member': Device object or None (if serial matches existing assignment)
}
]
"""
success, inventory = self.librenms_api.get_device_inventory(self.librenms_id)
if not success:
return []
# Filter for chassis components
chassis_components = [item for item in inventory if item.get("entPhysicalClass") == "chassis"]
# Get all VC members
vc_members = obj.virtual_chassis.members.all()
result = []
for component in chassis_components:
serial = component.get("entPhysicalSerialNum", "-")
if not serial or serial == "-":
continue
# Check if this serial is already assigned to a VC member
assigned_member = None
for member in vc_members:
if member.serial and member.serial.strip() == serial.strip():
assigned_member = member
break
result.append(
{
"description": component.get("entPhysicalDescr", "-"),
"serial": serial,
"model": component.get("entPhysicalModelName", "-"),
"assigned_member": assigned_member,
}
)
return result
def _get_platform_info(self, librenms_info, obj):
"""
Get platform information from LibreNMS.
Platform matching is based on OS name only (not version).
Version is displayed separately as informational data.
Args:
librenms_info: Dictionary with LibreNMS device info
obj: NetBox device object
Returns:
dict: {
'netbox_platform': Platform object or None,
'librenms_os': str (OS name),
'librenms_version': str (OS version),
'platform_exists': bool (whether OS platform exists in NetBox),
'platform_name': str (OS name for platform matching),
'matching_platform': Platform object or None
}
"""
from dcim.models import Platform
librenms_os = librenms_info["librenms_device_details"].get("librenms_device_os", "-")
librenms_version = librenms_info["librenms_device_details"].get("librenms_device_version", "-")
# Platform name is just the OS (not OS + version)
platform_name = librenms_os if librenms_os != "-" else None
# Check if platform exists (match by OS name only)
platform_exists = False
matching_platform = None
if platform_name:
try:
matching_platform = Platform.objects.get(name__iexact=platform_name)
platform_exists = True
except Platform.DoesNotExist:
pass
return {
"netbox_platform": obj.platform,
"librenms_os": librenms_os,
"librenms_version": librenms_version,
"platform_exists": platform_exists,
"platform_name": platform_name,
"matching_platform": matching_platform,
}

View File

@@ -0,0 +1,216 @@
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
server_key = self.librenms_api.server_key
cache.set(
self.get_cache_key(obj, "vlans", server_key),
vlans_data,
timeout=self.librenms_api.cache_timeout,
)
cache.set(
self.get_last_fetched_key(obj, "vlans", server_key),
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
server_key = getattr(self.librenms_api, "server_key", None)
cached_vlans = cache.get(self.get_cache_key(obj, "vlans", server_key))
last_fetched = cache.get(self.get_last_fetched_key(obj, "vlans", server_key))
# 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", server_key))
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl and cache_ttl > 0 else None
return {
"object": obj,
"vlan_table": vlan_table,
"vlan_groups": vlan_groups,
"last_fetched": last_fetched,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
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),
"server_key": getattr(self.librenms_api, "server_key", None),
}
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

View File

@@ -0,0 +1,27 @@
"""LibreNMS import workflow views."""
from .actions import ( # noqa: F401
BulkImportConfirmView,
BulkImportDevicesView,
DeviceClusterUpdateView,
DeviceConflictActionView,
DeviceRackUpdateView,
DeviceRoleUpdateView,
DeviceValidationDetailsView,
DeviceVCDetailsView,
SaveUserPrefView,
)
from .list import LibreNMSImportView # noqa: F401
__all__ = [
"BulkImportConfirmView",
"BulkImportDevicesView",
"DeviceClusterUpdateView",
"DeviceConflictActionView",
"DeviceRackUpdateView",
"DeviceRoleUpdateView",
"DeviceValidationDetailsView",
"DeviceVCDetailsView",
"LibreNMSImportView",
"SaveUserPrefView",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,480 @@
import logging
from dcim.models import Device
from django.contrib import messages
from django.core.cache import cache
from django.http import JsonResponse
from django.shortcuts import render
from netbox.views import generic
from utilities.rqworker import get_workers_for_queue
from netbox_librenms_plugin.forms import LibreNMSImportFilterForm
from netbox_librenms_plugin.import_utils import (
get_active_cached_searches,
process_device_filters,
)
from netbox_librenms_plugin.models import LibreNMSSettings
from netbox_librenms_plugin.tables.device_status import DeviceImportTable
from netbox_librenms_plugin.utils import get_user_pref
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
class LibreNMSImportView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""Import devices from LibreNMS into NetBox with validation metadata."""
queryset = Device.objects.none()
table = DeviceImportTable
filterset = None
filterset_form = LibreNMSImportFilterForm
template_name = "netbox_librenms_plugin/librenms_import.html"
actions = {}
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")
def should_use_background_job(self):
"""
Determine if filter operation should run as background job.
Background jobs provide active cancellation and keep the browser responsive
during long-running operations.
The main benefits of background jobs are:
- Active cancellation capability
- Browser responsiveness (no "page loading" hang)
- 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):
"""
Load cached results from a completed background job.
Args:
job_id: ID of the completed FilterDevicesJob
Returns:
List[dict]: Validated devices from job cache, or [] if cache expired
"""
from core.models import Job
try:
job = Job.objects.get(pk=job_id)
except Job.DoesNotExist:
logger.warning(f"Job {job_id} not found")
return []
if job.status != "completed":
logger.warning(f"Job {job_id} status is {job.status}, not completed")
return []
# Load cached devices from job using shared cache keys
from netbox_librenms_plugin.import_utils import get_validated_device_cache_key
job_data = job.data or {}
device_ids = job_data.get("device_ids", [])
filters = job_data.get("filters", {})
server_key = job_data.get("server_key", "default")
vc_enabled = job_data.get("vc_detection_enabled", False)
use_sysname = job_data.get("use_sysname", True)
strip_domain = job_data.get("strip_domain", False)
# Extract cache metadata for frontend warnings
self._cache_timestamp = job_data.get("cached_at")
self._cache_timeout = job_data.get("cache_timeout", 300)
# Preserve VC detection intent for follow-up actions (confirm/import)
self._vc_detection_enabled = vc_enabled
if not device_ids:
logger.warning(f"Job {job_id} missing device_ids")
return []
# Fetch devices from cache using shared keys
validated_devices = []
for device_id in device_ids:
cache_key = get_validated_device_cache_key(
server_key=server_key,
filters=filters,
device_id=device_id,
vc_enabled=vc_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
device = cache.get(cache_key)
if device:
validated_devices.append(device)
else:
logger.warning(f"Device {device_id} from job {job_id} not in cache (may have expired)")
if not validated_devices and device_ids:
logger.error(f"Job {job_id} cache expired. Processed {len(device_ids)} devices but none in cache.")
else:
# Mirror the job's naming settings so toggle state matches the cached results
self._use_sysname = use_sysname
self._strip_domain = strip_domain
return validated_devices
def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc
"""Render the import table backed by LibreNMS data."""
self._filter_warning = None
self._filter_form_data = {}
self._libre_filters = {}
self._cache_cleared = False
self._request = request # Store request for connection checks
self._job_results_loaded = False
self._from_cache = False
self._cache_timestamp = None
self._cache_timeout = 300
self._cache_metadata_missing = False
# Resolve naming preferences early so all paths (sync, background job,
# queryset loading) use the same use_sysname/strip_domain values.
# Cascade: user preference → plugin settings → defaults.
try:
settings_obj = LibreNMSSettings.objects.first()
except Exception:
logger.exception(
"Failed to read LibreNMSSettings during LibreNMS import for user %s",
getattr(request, "user", None),
)
settings_obj = None
_use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
_strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
if _use_sysname is None:
_use_sysname = getattr(settings_obj, "use_sysname_default", True) if settings_obj else True
if _strip_domain is None:
_strip_domain = getattr(settings_obj, "strip_domain_default", False) if settings_obj else False
self._use_sysname = _use_sysname
self._strip_domain = _strip_domain
self._settings = settings_obj
# Determine if new filters are being submitted
libre_filter_fields = (
"librenms_location",
"librenms_type",
"librenms_os",
"librenms_hostname",
"librenms_sysname",
"librenms_hardware",
)
filters_present = any(request.GET.get(field) for field in libre_filter_fields)
filters_submitted = request.GET.get("apply_filters") or filters_present
# Check if loading results from completed background job
# Only load job results if NOT submitting new filters
job_id = request.GET.get("job_id")
if job_id and not filters_submitted:
try:
job_id = int(job_id)
logger.info(f"Loading results from job {job_id}")
validated_devices = self._load_job_results(job_id)
if validated_devices:
self._import_data = validated_devices
self._job_results_loaded = True
# Job results are cached data, so mark as from_cache
self._from_cache = True
# Extract filter info from first device's cache or job data
# This allows the filter form to show what was searched
else:
messages.warning(
request,
"Job results have expired. Please re-apply your filters.",
)
except (ValueError, TypeError):
logger.warning(f"Invalid job_id parameter: {request.GET.get('job_id')}")
raw_enable_flag = request.GET.get("enable_vc_detection")
legacy_skip_flag = request.GET.get("skip_vc_detection")
truthy_values = {"1", "true", "on", "True"}
if raw_enable_flag is not None:
self._vc_detection_enabled = raw_enable_flag in truthy_values
elif legacy_skip_flag is not None:
legacy_skip = legacy_skip_flag in truthy_values
self._vc_detection_enabled = not legacy_skip
else:
self._vc_detection_enabled = getattr(self, "_vc_detection_enabled", False)
filter_form = self.filterset_form(request.GET) if self.filterset_form else None
form_valid = False # Track form validity
if filter_form:
form_valid = filter_form.is_valid()
if form_valid:
self._filter_form_data = filter_form.cleaned_data
self._vc_detection_enabled = self._filter_form_data.get("enable_vc_detection")
self._cache_cleared = self._filter_form_data.get("clear_cache")
elif filters_submitted:
non_field_errors = filter_form.non_field_errors()
if non_field_errors:
self._filter_warning = non_field_errors[0]
self._filters_submitted = filters_submitted
# Check if this should be processed as a background job
# Skip if we're loading results from a completed job (job_id in URL)
# IMPORTANT: Only process if form is valid (filter requirement enforced)
device_count = 0
if filters_submitted and form_valid and not self._job_results_loaded and not request.GET.get("job_id"):
# Build filter dict
libre_filters = {}
if location := request.GET.get("librenms_location"):
libre_filters["location"] = location
if device_type := request.GET.get("librenms_type"):
libre_filters["type"] = device_type
if os := request.GET.get("librenms_os"):
libre_filters["os"] = os
if hostname := request.GET.get("librenms_hostname"):
libre_filters["hostname"] = hostname
if sysname := request.GET.get("librenms_sysname"):
libre_filters["sysname"] = sysname
if hardware := request.GET.get("librenms_hardware"):
libre_filters["hardware"] = hardware
from netbox_librenms_plugin.import_utils import (
get_cache_metadata_key,
get_device_count_for_filters,
)
# Check if validated results already exist for this naming-mode
# namespace. The metadata key encodes server, filters, vc_enabled,
# use_sysname and strip_domain, so a naming-preference change
# correctly shows the cache as cold and triggers the background path.
validated_cached = False
if not self._cache_cleared:
try:
metadata_key = get_cache_metadata_key(
server_key=self.librenms_api.server_key,
filters=libre_filters,
vc_enabled=self._vc_detection_enabled,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
validated_cached = cache.get(metadata_key) is not None
except Exception as e:
logger.debug("Cache check failed; proceeding without cached result: %s", e, exc_info=True)
# Get device count for background job decision
try:
device_count = get_device_count_for_filters(
api=self.librenms_api,
filters=libre_filters,
clear_cache=self._cache_cleared,
show_disabled=bool(self._filter_form_data.get("show_disabled", False)),
)
except Exception as e:
logger.error(f"Error getting device count: {e}")
device_count = 0
# Decide whether to use background job
# Skip background job if validated data is already cached
if not validated_cached and self.should_use_background_job():
# Check if RQ workers are available
if get_workers_for_queue("default") > 0:
from netbox_librenms_plugin.jobs import FilterDevicesJob
# Enqueue background job
job = FilterDevicesJob.enqueue(
user=request.user,
filters=libre_filters,
vc_detection_enabled=self._vc_detection_enabled,
clear_cache=self._cache_cleared,
show_disabled=bool(self._filter_form_data.get("show_disabled")),
exclude_existing=bool(self._filter_form_data.get("exclude_existing")),
server_key=self.librenms_api.server_key,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
logger.info(
f"Enqueued FilterDevicesJob {job.pk} (UUID: {job.job_id}) for user {request.user} - {device_count} devices"
)
# Return JSON for AJAX polling
# Use background-tasks endpoint to poll Redis queue (where job actually runs)
# IMPORTANT: Use job.job_id (UUID) for background-tasks API, but job.pk for result loading
return JsonResponse(
{
"job_id": str(job.job_id), # UUID for API polling
"job_pk": job.pk, # Integer PK for result loading
"use_polling": True,
"poll_url": f"/api/core/background-tasks/{job.job_id}/",
"device_count": device_count,
}
)
else:
# Fallback to synchronous processing
logger.warning("RQ workers not running, falling back to synchronous processing")
messages.warning(
request,
"Background job system unavailable. Processing may take longer than usual.",
)
queryset = self.get_queryset(request)
table = self.get_table(queryset, request, bulk_actions=True)
filter_warning = self._filter_warning
# Get active cached searches for this server
cached_searches = get_active_cached_searches(self.librenms_api.server_key)
context = {
"model": Device,
"table": table,
"filter_form": filter_form,
"title": self.title,
"filter_warning": filter_warning,
"filters_submitted": filters_submitted,
"show_filter_warning": bool(filter_warning),
"settings": self._settings,
"use_sysname": self._use_sysname,
"strip_domain": self._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),
"cache_timestamp": getattr(self, "_cache_timestamp", None),
"cache_timeout": getattr(self, "_cache_timeout", 300),
"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,
"device_count": device_count,
}
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"),
)
return table
def _get_import_queryset(self):
# Return job results if already loaded
if getattr(self, "_job_results_loaded", False):
return getattr(self, "_import_data", [])
if not getattr(self, "_filters_submitted", False):
self._libre_filters = {}
return []
if self._filter_warning:
self._libre_filters = {}
return []
data_source = getattr(self, "_filter_form_data", None) or {}
libre_filters = {}
vc_detection_enabled = (
data_source.get("enable_vc_detection")
if "enable_vc_detection" in data_source
else getattr(self, "_vc_detection_enabled", False)
)
clear_cache = (
data_source.get("clear_cache") if "clear_cache" in data_source else getattr(self, "_cache_cleared", False)
)
self._vc_detection_enabled = vc_detection_enabled
self._cache_cleared = clear_cache
if location := data_source.get("librenms_location"):
libre_filters["location"] = location
if device_type := data_source.get("librenms_type"):
libre_filters["type"] = device_type
if os := data_source.get("librenms_os"):
libre_filters["os"] = os
if hostname := data_source.get("librenms_hostname"):
libre_filters["hostname"] = hostname
if sysname := data_source.get("librenms_sysname"):
libre_filters["sysname"] = sysname
if hardware := data_source.get("librenms_hardware"):
libre_filters["hardware"] = hardware
self._libre_filters = libre_filters
# Form validation already ensures at least one filter is present
# No need for redundant check here
# Use shared processing function (same logic as background job)
show_disabled = bool(data_source.get("show_disabled"))
exclude_existing = bool(data_source.get("exclude_existing"))
validated_devices, from_cache = process_device_filters(
api=self.librenms_api,
filters=libre_filters,
vc_detection_enabled=vc_detection_enabled,
clear_cache=clear_cache,
show_disabled=show_disabled,
exclude_existing=exclude_existing,
request=self._request,
return_cache_status=True,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
self._from_cache = from_cache
# Retrieve cache metadata (timestamp) for countdown display
# This works for both new caches and existing caches
if validated_devices:
from netbox_librenms_plugin.import_utils import get_cache_metadata_key
cache_metadata_key = get_cache_metadata_key(
server_key=self.librenms_api.server_key,
filters=libre_filters,
vc_enabled=vc_detection_enabled,
use_sysname=self._use_sysname,
strip_domain=self._strip_domain,
)
cache_metadata = cache.get(cache_metadata_key)
if cache_metadata:
self._cache_timestamp = cache_metadata.get("cached_at")
self._cache_timeout = cache_metadata.get("cache_timeout", 300)
self._cache_metadata_missing = False
logger.info(
f"Retrieved cache metadata: timestamp={self._cache_timestamp}, "
f"timeout={self._cache_timeout}, from_cache={from_cache}"
)
else:
self._cache_metadata_missing = True
logger.warning(
f"Cache metadata not found for key: {cache_metadata_key}, from_cache={from_cache}. "
f"This may indicate cache key mismatch or metadata expiration."
)
# Mark each device's validation with VC detection flag for downstream views
for device in validated_devices:
if "_validation" in device:
device["_validation"]["_vc_detection_enabled"] = vc_detection_enabled
return validated_devices

View File

@@ -0,0 +1,86 @@
from netbox.views import generic
from utilities.views import register_model_view
from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet
from netbox_librenms_plugin.forms import (
InterfaceTypeMappingFilterForm,
InterfaceTypeMappingForm,
InterfaceTypeMappingImportForm,
)
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(LibreNMSPermissionMixin, generic.ObjectListView):
"""
Provides a view for listing all `InterfaceTypeMapping` objects.
"""
queryset = InterfaceTypeMapping.objects.all()
table = InterfaceTypeMappingTable
filterset = InterfaceTypeMappingFilterSet
filterset_form = InterfaceTypeMappingFilterForm
template_name = "netbox_librenms_plugin/interfacetypemapping_list.html"
class InterfaceTypeMappingCreateView(LibreNMSPermissionMixin, generic.ObjectEditView):
"""
Provides a view for creating a new `InterfaceTypeMapping` object.
"""
queryset = InterfaceTypeMapping.objects.all()
form = InterfaceTypeMappingForm
@register_model_view(InterfaceTypeMapping, "bulk_import", path="import", detail=False)
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.
"""
queryset = InterfaceTypeMapping.objects.all()
model_form = InterfaceTypeMappingImportForm
class InterfaceTypeMappingView(LibreNMSPermissionMixin, generic.ObjectView):
"""
Provides a view for displaying details of a specific `InterfaceTypeMapping` object.
"""
queryset = InterfaceTypeMapping.objects.all()
class InterfaceTypeMappingEditView(LibreNMSPermissionMixin, generic.ObjectEditView):
"""
Provides a view for editing a specific `InterfaceTypeMapping` object.
"""
queryset = InterfaceTypeMapping.objects.all()
form = InterfaceTypeMappingForm
class InterfaceTypeMappingDeleteView(LibreNMSPermissionMixin, generic.ObjectDeleteView):
"""
Provides a view for deleting a specific `InterfaceTypeMapping` object.
"""
queryset = InterfaceTypeMapping.objects.all()
class InterfaceTypeMappingBulkDeleteView(LibreNMSPermissionMixin, generic.BulkDeleteView):
"""
Provides a view for deleting multiple `InterfaceTypeMapping` objects.
"""
queryset = InterfaceTypeMapping.objects.all()
table = InterfaceTypeMappingTable
class InterfaceTypeMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView):
"""
Provides a view for displaying the change log of a specific `InterfaceTypeMapping` object.
"""
queryset = InterfaceTypeMapping.objects.all()

View File

@@ -0,0 +1,693 @@
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.
This mixin initializes a LibreNMSAPI instance and provides a property
to access it. It's designed to be used with other view classes that
need to interact with the LibreNMS API.
Attributes:
_librenms_api (LibreNMSAPI): An instance of the LibreNMSAPI class.
Properties:
librenms_api (LibreNMSAPI): A property that returns the LibreNMSAPI instance,
creating it if it doesn't exist.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._librenms_api = None
@property
def librenms_api(self):
"""
Get or create an instance of LibreNMSAPI.
This property ensures that only one instance of LibreNMSAPI is created
and reused for subsequent calls. The API instance will use the currently
selected server from settings.
Returns:
LibreNMSAPI: An instance of the LibreNMSAPI class.
"""
if self._librenms_api is None:
# The LibreNMSAPI will automatically use the selected server
self._librenms_api = LibreNMSAPI()
return self._librenms_api
def get_server_info(self):
"""
Get information about the currently active LibreNMS server.
Returns:
dict: Server information including display name and URL
"""
try:
# Get the current server key
server_key = self.librenms_api.server_key
# Try to get multi-server configuration
from netbox.plugins import get_plugin_config
servers_config = get_plugin_config("netbox_librenms_plugin", "servers")
if servers_config and isinstance(servers_config, dict) and server_key in servers_config:
# Multi-server configuration
config = servers_config[server_key]
return {
"display_name": config.get("display_name", server_key),
"url": config["librenms_url"],
"is_legacy": False,
"server_key": server_key,
}
else:
# Legacy configuration
legacy_url = get_plugin_config("netbox_librenms_plugin", "librenms_url")
return {
"display_name": "Default Server",
"url": legacy_url or "Not configured",
"is_legacy": True,
"server_key": "default",
}
except (KeyError, AttributeError, ImportError):
return {
"display_name": "Unknown Server",
"url": "Configuration error",
"is_legacy": True,
"server_key": "unknown",
}
def get_context_data(self, **kwargs):
"""Add server info to context for all views using this mixin."""
try:
context = super().get_context_data(**kwargs)
except AttributeError:
context = kwargs
context["librenms_server_info"] = self.get_server_info()
return context
class CacheMixin:
"""
A mixin class that provides caching functionality.
"""
def get_cache_key(self, obj, data_type="ports", server_key=None):
"""
Get the cache key for the object.
Args:
obj: The object to cache data for
data_type: Type of data being cached ('ports', 'links', 'inventory', etc.)
server_key: Optional LibreNMS server key for namespacing per-server data
"""
model_name = obj._meta.model_name
base = f"librenms_{data_type}_{model_name}_{obj.pk}"
if server_key:
return f"{base}_{server_key}"
return base
def get_last_fetched_key(self, obj, data_type="ports", server_key=None):
"""
Get the cache key for the last fetched time of the object.
"""
model_name = obj._meta.model_name
base = f"librenms_{data_type}_last_fetched_{model_name}_{obj.pk}"
if server_key:
return f"{base}_{server_key}"
return base
def get_vlan_overrides_key(self, obj, server_key=None):
"""
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. Including server_key scopes
overrides per-server to avoid leakage when multiple servers are configured.
"""
model_name = obj._meta.model_name
if server_key:
return f"librenms_vlan_group_overrides_{model_name}_{obj.pk}_{server_key}"
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 (group VLANs only)
if group:
if vid not in vid_to_groups:
vid_to_groups[vid] = []
if 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 and obj.pk 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,
}

View File

@@ -0,0 +1,18 @@
"""Views backing the LibreNMS sync tabs on Device and VM detail pages."""
from .devices import ( # noqa: F401
DeviceCableTableView,
DeviceInterfaceTableView,
DeviceIPAddressTableView,
DeviceLibreNMSSyncView,
DeviceVLANTableView,
SaveVlanGroupOverridesView,
SingleInterfaceVerifyView,
SingleVlanGroupVerifyView,
VerifyVlanSyncGroupView,
)
from .vms import ( # noqa: F401
VMInterfaceTableView,
VMIPAddressTableView,
VMLibreNMSSyncView,
)

View File

@@ -0,0 +1,395 @@
import json
from dcim.models import Device
from django.core.cache import cache
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
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,
)
from netbox_librenms_plugin.tables.interfaces import (
LibreNMSInterfaceTable,
VCInterfaceTable,
)
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_sync_device,
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 ..base.vlan_table_view import BaseVLANTableView
from ..mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
@register_model_view(Device, name="librenms_sync", path="librenms-sync")
class DeviceLibreNMSSyncView(BaseLibreNMSSyncView):
"""Device detail tab showing LibreNMS sync information."""
queryset = Device.objects.all()
model = 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."""
model = Device
def get_interfaces(self, obj):
"""Return all interfaces for the device."""
return obj.interfaces.all()
def get_redirect_url(self, obj):
"""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, vlan_groups=None):
"""Return the appropriate interface table, selecting VC variant if needed."""
server_key = self.librenms_api.server_key
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
table = VCInterfaceTable(
data,
device=obj,
interface_name_field=interface_name_field,
vlan_groups=vlan_groups,
server_key=server_key,
)
else:
table = LibreNMSInterfaceTable(
data,
device=obj,
interface_name_field=interface_name_field,
vlan_groups=vlan_groups,
server_key=server_key,
)
table.htmx_url = f"{self.request.path}?tab=interfaces" + (f"&server_key={server_key}" if server_key else "")
return table
class SingleInterfaceVerifyView(LibreNMSPermissionMixin, LibreNMSAPIMixin, 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")
interface_name_field = data.get("interface_name_field") or get_interface_name_field()
server_key = data.get("server_key") or self.librenms_api.server_key
if not selected_device_id:
return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400)
selected_device = get_object_or_404(Device, pk=selected_device_id)
# Normalise to the VC sync device so cache keys match what the sync view stored
if selected_device.virtual_chassis:
primary_device = get_librenms_sync_device(selected_device, server_key=server_key)
if primary_device is None:
return JsonResponse(
{"status": "error", "message": "No sync device found for virtual chassis"}, status=404
)
else:
primary_device = selected_device
cached_data = cache.get(self.get_cache_key(primary_device, "ports", server_key))
if cached_data:
port_data = next(
(port for port in cached_data.get("ports", []) if port.get(interface_name_field) == interface_name),
None,
)
if port_data:
table_class = VCInterfaceTable if selected_device.virtual_chassis else LibreNMSInterfaceTable
table = table_class(
[],
device=selected_device,
interface_name_field=interface_name_field,
server_key=server_key,
)
formatted_row = table.format_interface_data(port_data, selected_device)
return JsonResponse({"status": "success", "formatted_row": formatted_row})
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'<span class="{css}">{untagged}(U){warning}</span>')
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'<span class="{css}">{vid}(T){warning}</span>')
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, LibreNMSAPIMixin, 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", {})
server_key = data.get("server_key") or self.librenms_api.server_key
if not device_id:
return JsonResponse({"status": "error", "message": "No device ID provided"}, status=400)
device = get_object_or_404(Device, pk=device_id)
# Normalise to the VC sync device so cache keys match what the sync view stored
sync_device = get_librenms_sync_device(device, server_key=server_key)
if sync_device is None:
sync_device = device
# Use the remaining TTL of the ports cache so both expire together
ports_ttl = cache.ttl(self.get_cache_key(sync_device, "ports", server_key))
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(sync_device, server_key)) or {}
existing.update(vid_group_map)
cache.set(self.get_vlan_overrides_key(sync_device, server_key), 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)
class DeviceIPAddressTableView(BaseIPAddressTableView):
"""IP address synchronization view for Devices."""
model = Device
class DeviceVLANTableView(BaseVLANTableView):
"""VLAN synchronization table view for Devices."""
model = Device

View File

@@ -0,0 +1,77 @@
import copy
from django.urls import reverse
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
from ..base.interfaces_view import BaseInterfaceTableView
from ..base.ip_addresses_view import BaseIPAddressTableView
from ..base.librenms_sync_view import BaseLibreNMSSyncView
@register_model_view(VirtualMachine, name="librenms_sync", path="librenms-sync")
class VMLibreNMSSyncView(BaseLibreNMSSyncView):
"""Virtual machine detail tab for LibreNMS sync data."""
queryset = VirtualMachine.objects.all()
model = VirtualMachine
tab = ViewTab(
label="LibreNMS Sync",
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()
interface_sync_view.request = copy.copy(request)
return interface_sync_view.get_context_data(interface_sync_view.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_vlan_context(self, request, obj):
"""Return None; VMs do not support VLAN sync."""
return None
def get_ip_context(self, request, obj):
"""Return IP address sync context for the virtual machine."""
ipaddress_sync_view = VMIPAddressTableView()
ipaddress_sync_view.request = copy.copy(request)
return ipaddress_sync_view.get_context_data(ipaddress_sync_view.request, obj)
class VMInterfaceTableView(BaseInterfaceTableView):
"""Interface synchronization view for Virtual Machines."""
model = VirtualMachine
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,
server_key=self.librenms_api.server_key,
interface_name_field=interface_name_field,
)
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})
class VMIPAddressTableView(BaseIPAddressTableView):
"""IP address synchronization view for Virtual Machines."""
model = VirtualMachine
# VM-specific implementations

View File

@@ -0,0 +1,194 @@
import logging
from django.contrib import messages
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(LibreNMSPermissionMixin, View):
"""
View for managing plugin settings including server selection and import options.
Uses two separate forms for cleaner validation and separation of concerns.
"""
template_name = "netbox_librenms_plugin/settings.html"
def get(self, request):
"""Display both settings forms."""
# Get or create the settings object
settings, created = LibreNMSSettings.objects.get_or_create()
# Instantiate both forms
server_form = ServerConfigForm(instance=settings)
import_form = ImportSettingsForm(instance=settings)
return render(
request,
self.template_name,
{
"server_form": server_form,
"import_form": import_form,
"object": settings,
},
)
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()
# Determine which form was submitted
form_type = request.POST.get("form_type")
if form_type == "server_config":
# Process server configuration form
server_form = ServerConfigForm(request.POST, instance=settings)
import_form = ImportSettingsForm(instance=settings) # Unbound form for display
if server_form.is_valid():
server_form.save()
messages.success(
request,
f"LibreNMS server settings updated successfully. Active server: {server_form.cleaned_data['selected_server']}",
)
return redirect("plugins:netbox_librenms_plugin:settings")
elif form_type == "import_settings":
# Process import settings form
server_form = ServerConfigForm(instance=settings) # Unbound form for display
import_form = ImportSettingsForm(request.POST, instance=settings)
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.",
)
return redirect("plugins:netbox_librenms_plugin:settings")
else:
# Unknown form_type - shouldn't happen, but handle gracefully
messages.error(request, "Invalid form submission.")
return redirect("plugins:netbox_librenms_plugin:settings")
# If we get here, validation failed - render both forms
return render(
request,
self.template_name,
{
"server_form": server_form,
"import_form": import_form,
"object": settings,
"active_tab": form_type, # Pass which tab should be active
},
)
class TestLibreNMSConnectionView(LibreNMSPermissionMixin, View):
"""
HTMX view to test LibreNMS server connection.
Returns HTML fragment instead of JSON for HTMX compatibility.
"""
def post(self, request):
"""Test connection to selected LibreNMS server."""
server_key = request.POST.get("selected_server")
if not server_key:
return HttpResponse(
'<div class="alert alert-warning">'
'<i class="ti ti-alert-circle me-2"></i>'
"<strong>No server selected.</strong> Please select a server first."
"</div>"
)
try:
# Initialize LibreNMS API client
api_client = LibreNMSAPI(server_key=server_key)
# Test the connection by calling the /system endpoint
system_info = api_client.test_connection()
if system_info and not system_info.get("error"):
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'<div class="alert alert-success">'
f'<i class="ti ti-check me-2"></i>'
f"<strong>Connection successful!</strong><br>"
f"LibreNMS Version: {version}<br>"
f"Database: {database}<br>"
f"PHP Version: {php_version}"
f"</div>"
)
elif system_info and system_info.get("error"):
error_msg = escape(system_info.get("message", "Unknown error occurred"))
return HttpResponse(
f'<div class="alert alert-danger">'
f'<i class="ti ti-alert-circle me-2"></i>'
f"<strong>Connection failed:</strong><br>{error_msg}"
f"</div>"
)
else:
return HttpResponse(
'<div class="alert alert-danger">'
'<i class="ti ti-alert-circle me-2"></i>'
"<strong>Connection failed:</strong><br>"
"Failed to retrieve system information"
"</div>"
)
except ValueError as e:
return HttpResponse(
f'<div class="alert alert-danger">'
f'<i class="ti ti-alert-circle me-2"></i>'
f"<strong>Configuration error:</strong><br>{escape(str(e))}"
f"</div>"
)
except Exception as e:
return HttpResponse(
f'<div class="alert alert-danger">'
f'<i class="ti ti-alert-circle me-2"></i>'
f"<strong>Connection failed:</strong><br>{escape(str(e))}"
f"</div>"
)

View File

@@ -0,0 +1,117 @@
import logging
from dcim.models import Device
from django.db.models import BooleanField, Case, Value, When
from netbox.views import generic
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.filtersets import DeviceStatusFilterSet, VMStatusFilterSet
from netbox_librenms_plugin.forms import (
DeviceStatusFilterForm,
VirtualMachineStatusFilterForm,
)
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, LibreNMSPermissionMixin
logger = logging.getLogger(__name__)
class DeviceStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Check the status of NetBox devices in LibreNMS.
Shows NetBox devices with their LibreNMS status.
"""
queryset = Device.objects.none() # Start with empty queryset
table = DeviceStatusTable
filterset = DeviceStatusFilterSet
filterset_form = DeviceStatusFilterForm
template_name = "netbox_librenms_plugin/status_check.html"
actions = {}
title = "Device LibreNMS Status"
def get_queryset(self, request):
"""
Override get_queryset to return filtered devices and check LibreNMS status
"""
# Only get devices if filters are applied
if self.request.GET:
queryset = Device.objects.select_related("device_type__manufacturer").prefetch_related(
"site",
"location",
"rack",
)
# Create a list to store device IDs and their status
device_status_map = {}
# Apply filters
queryset = self.filterset(self.request.GET, queryset=queryset).qs
# Check LibreNMS status for each device
for device in queryset:
try:
librenms_id = self.librenms_api.get_librenms_id(device)
device_status_map[device.pk] = bool(librenms_id)
except Exception:
device_status_map[device.pk] = False
# Annotate the queryset with the status values
case_when = []
for device_id, status in device_status_map.items():
case_when.append(When(pk=device_id, then=Value(status)))
queryset = queryset.annotate(
librenms_status=Case(*case_when, default=Value(None), output_field=BooleanField())
)
return queryset
return Device.objects.none()
class VMStatusListView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Check the status of virtual machines in NetBox against LibreNMS
"""
queryset = VirtualMachine.objects.select_related("cluster", "site")
table = VMStatusTable
filterset = VMStatusFilterSet
filterset_form = VirtualMachineStatusFilterForm
template_name = "netbox_librenms_plugin/status_check.html"
actions = {}
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")
# Create a list to store VM IDs and their status
vm_status_map = {}
# Apply filters
queryset = self.filterset(self.request.GET, queryset=queryset).qs
# Check LibreNMS status for each VM
for vm in queryset:
try:
librenms_id = self.librenms_api.get_librenms_id(vm)
vm_status_map[vm.pk] = bool(librenms_id)
except Exception:
vm_status_map[vm.pk] = False
# Annotate the queryset with the status values
case_when = []
for vm_id, status in vm_status_map.items():
case_when.append(When(pk=vm_id, then=Value(status)))
queryset = queryset.annotate(
librenms_status=Case(*case_when, default=Value(None), output_field=BooleanField())
)
return queryset
return VirtualMachine.objects.none()

View File

@@ -0,0 +1,237 @@
import logging
from urllib.parse import quote_plus
from dcim.models import Cable, Device, Interface
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from netbox_librenms_plugin.utils import get_librenms_sync_device
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
logger = logging.getLogger(__name__)
class SyncCablesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, 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.
Each ``select`` value is a ``local_port_id`` (stable LibreNMS identifier)
so that matching against cached link data is user-preference agnostic.
"""
selected_interfaces = []
selected_data = [x for x in request.POST.getlist("select") if x]
if not selected_data:
return None
for port_id in selected_data:
override = request.POST.get(f"device_selection_{port_id}")
device_id = override or initial_device.id
selected_interfaces.append({"device_id": device_id, "local_port_id": port_id})
return selected_interfaces
def get_cached_links_data(self, request, obj):
"""Return cached LibreNMS link data for the given object."""
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
cache_obj = get_librenms_sync_device(obj, server_key=server_key) or obj
cached_data = cache.get(self.get_cache_key(cache_obj, "links", server_key))
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.
Returns:
True on success, False on failure.
"""
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."""
from dcim.models import Interface as DCIMInterface
interface_ct = ContentType.objects.get_for_model(DCIMInterface)
return Cable.objects.filter(
Q(
terminations__termination_type=interface_ct,
terminations__termination_id=local_interface.pk,
)
| Q(
terminations__termination_type=interface_ct,
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,
"Cache has expired. Please refresh the cable data before syncing.",
)
return False
if selected_interfaces is None:
messages.error(self.request, "No interfaces selected for synchronization.")
return False
return True
def process_single_interface(self, interface, cached_links):
"""Process cable creation for a single interface from cached link data."""
port_id = str(interface.get("local_port_id", ""))
try:
link_data = next(link for link in cached_links if str(link.get("local_port_id", "")) == port_id)
# Apply posted device_id (VC member selection) without mutating the cached list.
link_data = {**link_data, "device_id": interface.get("device_id", link_data.get("device_id"))}
return self.handle_cable_creation(link_data, interface)
except StopIteration:
return {"status": "invalid", "interface": port_id}
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_interface_id",
]
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."""
display_name = link_data.get("local_port") or interface.get("local_port_id", "")
if not self.verify_cable_creation_requirements(link_data):
if not link_data.get("netbox_remote_device_id") or not link_data.get("netbox_remote_interface_id"):
return {"status": "missing_remote", "interface": display_name}
return {"status": "invalid", "interface": display_name}
try:
local_interface = Interface.objects.get(pk=link_data["netbox_local_interface_id"])
# Honour user's VC member selection: if the selected device_id differs from
# the cached interface's device, look up the same port name on that device.
selected_device_id = interface.get("device_id")
if selected_device_id and str(local_interface.device_id) != str(selected_device_id):
port_name = link_data.get("local_port") or local_interface.name
try:
local_interface = Interface.objects.get(device_id=selected_device_id, name=port_name)
except Interface.DoesNotExist:
logger.debug(
"Port %s not found on device %s; falling back to cached interface",
port_name,
selected_device_id,
)
remote_interface = Interface.objects.get(pk=link_data["netbox_remote_interface_id"])
if self.check_existing_cable(local_interface, remote_interface):
return {"status": "duplicate", "interface": display_name}
if self.create_cable(local_interface, remote_interface, self.request):
return {"status": "valid", "interface": display_name}
return {"status": "invalid", "interface": display_name} # pragma: no cover
except Interface.DoesNotExist:
return {"status": "missing_remote", "interface": display_name}
def process_interface_sync(self, selected_interfaces, cached_links):
"""
Process cable sync for all selected interfaces and return results.
Each interface is processed in its own atomic block so individual
failures roll back only that cable without affecting others.
"""
results = {"valid": [], "invalid": [], "duplicate": [], "missing_remote": []}
for interface in selected_interfaces:
try:
with transaction.atomic():
result = self.process_single_interface(interface, cached_links)
results[result["status"]].append(result.get("interface", ""))
except Exception:
logger.exception("Failed to sync cable for port_id %s", interface.get("local_port_id", ""))
results["invalid"].append(interface.get("local_port_id", ""))
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)
server_key = request.POST.get("server_key") or self.librenms_api.server_key
self._post_server_key = server_key
self._initial_device = initial_device
redirect_url = (
f"{reverse('plugins:netbox_librenms_plugin:device_librenms_sync', args=[initial_device.pk])}?tab=cables"
+ (f"&server_key={quote_plus(server_key)}" if server_key else "")
)
selected_interfaces = self.get_selected_interfaces(request, initial_device)
cached_links = self.get_cached_links_data(request, initial_device)
if not self.validate_prerequisites(cached_links, selected_interfaces):
return redirect(redirect_url)
results = self.process_interface_sync(selected_interfaces, cached_links)
self.display_sync_results(request, results)
return redirect(redirect_url)
def display_sync_results(self, request, results):
"""Display flash messages summarizing the cable sync results."""
if results["missing_remote"]:
messages.error(
request,
f"Remote device or interface not found in NetBox for: {', '.join(results['missing_remote'])}",
)
if results["invalid"]:
messages.error(
request,
f"No LibreNMS link data found for interfaces: {', '.join(results['invalid'])}",
)
if results["duplicate"]:
messages.warning(
request,
f"Cable already exists for interfaces: {', '.join(results['duplicate'])}",
)
if results["valid"]:
messages.success(
request,
f"Successfully created cable for interfaces: {', '.join(results['valid'])}",
)

View File

@@ -0,0 +1,713 @@
import logging
from dcim.models import Device, Manufacturer, Platform
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.import_utils import _determine_device_name
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
from netbox_librenms_plugin.utils import (
find_by_librenms_id,
get_librenms_sync_device,
match_librenms_hardware_to_device_type,
migrate_legacy_librenms_id,
resolve_naming_preferences,
)
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin
logger = logging.getLogger(__name__)
class UpdateDeviceNameView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""Update NetBox device name from LibreNMS sysName."""
required_object_permissions = {
"POST": [("change", Device)],
}
def post(self, request, pk):
"""Sync the device name from LibreNMS sysName."""
if error := self.require_all_permissions("POST"):
return error
device = get_object_or_404(Device, pk=pk)
# For VC members without their own librenms_id, use the VC sync device
librenms_lookup_device = device
if hasattr(device, "virtual_chassis") and device.virtual_chassis:
if not device.cf.get("librenms_id"):
sync_device = get_librenms_sync_device(device)
if sync_device:
librenms_lookup_device = sync_device
self.librenms_id = self.librenms_api.get_librenms_id(librenms_lookup_device)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
# Bail out early when LibreNMS has no usable name the fallback
# names that _determine_device_name generates (e.g. "device-42")
# are only useful during import, not for renaming an existing device.
if not (device_info.get("sysName") or device_info.get("hostname")):
messages.warning(request, "No name could be determined from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
use_sysname, strip_domain = resolve_naming_preferences(request)
resolved_name = _determine_device_name(
device_info,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# For VC members, generate the expected VC member name
if (
resolved_name
and hasattr(device, "virtual_chassis")
and device.virtual_chassis is not None
and device.vc_position is not None
):
resolved_name = _generate_vc_member_name(
resolved_name,
device.vc_position,
serial=getattr(device, "serial", None),
)
if not resolved_name:
messages.warning(request, "No name could be determined from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_name = device.name
device.name = resolved_name
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.name = old_name
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update device name to '{resolved_name}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(request, f"Device name updated from '{old_name}' to '{resolved_name}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
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)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
serial = device_info.get("serial")
if not serial or serial == "-":
messages.warning(request, "No serial number available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_serial = device.serial
device.serial = serial
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(
request,
f"Device serial updated from '{old_serial}' to '{serial}'",
)
else:
messages.success(request, f"Device serial set to '{serial}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
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)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
hardware = device_info.get("hardware")
if not hardware:
messages.warning(request, "No hardware information available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
match_result = match_librenms_hardware_to_device_type(hardware)
if not match_result or not match_result["matched"]:
messages.error(
request,
f"No matching DeviceType found for hardware '{hardware}'",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
device_type = match_result["device_type"]
old_device_type = device.device_type
device.device_type = device_type
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,
f"Device type updated from '{old_device_type}' to '{device_type}'",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
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)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if not success or not device_info:
messages.error(request, "Failed to retrieve device info from LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
os_name = device_info.get("os")
if not os_name:
messages.warning(request, "No OS information available in LibreNMS")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
platform_name = os_name
try:
platform = Platform.objects.get(name__iexact=platform_name)
except Platform.DoesNotExist:
messages.error(
request,
"Platform '{}' does not exist in NetBox. Use 'Create & Sync' button to create it first.".format(
platform_name
),
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
old_platform = device.platform
device.platform = platform
try:
device.full_clean()
device.save()
except (ValidationError, IntegrityError) as e:
device.platform = old_platform
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
messages.error(request, f"Failed to update platform to '{platform}': {error_msg}")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if old_platform:
messages.success(
request,
f"Device platform updated from '{old_platform}' to '{platform}'",
)
else:
messages.success(request, f"Device platform set to '{platform}'")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
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")
manufacturer_id = request.POST.get("manufacturer")
if not platform_name:
messages.error(request, "Platform name is required")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
if Platform.objects.filter(name__iexact=platform_name).exists():
messages.warning(
request,
f"Platform '{platform_name}' already exists. Use the regular sync button.",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
manufacturer = None
if manufacturer_id:
try:
manufacturer = Manufacturer.objects.get(pk=manufacturer_id)
except Manufacturer.DoesNotExist:
pass
with transaction.atomic():
try:
platform = Platform(
name=platform_name,
manufacturer=manufacturer,
)
platform.full_clean()
platform.save()
except ValidationError as e:
transaction.set_rollback(True)
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
logger.exception(
"ValidationError creating platform '%s' for device pk=%s: %s",
platform_name,
pk,
error_msg,
)
messages.error(
request,
f"Platform '{platform_name}' could not be created: {error_msg}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
except IntegrityError as e:
transaction.set_rollback(True)
logger.exception(
"IntegrityError creating platform '%s' for device pk=%s",
platform_name,
pk,
)
messages.error(
request,
f"Platform '{platform_name}' could not be created: {e}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
try:
device = Device.objects.select_for_update().get(pk=pk)
except Device.DoesNotExist:
transaction.set_rollback(True)
messages.error(request, "Device no longer exists.")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
device.platform = platform
try:
device.full_clean()
except ValidationError as e:
transaction.set_rollback(True)
error_msg = e.message_dict if hasattr(e, "message_dict") else str(e)
logger.exception(
"ValidationError validating device pk=%s: %s",
pk,
error_msg,
)
messages.error(
request,
f"Device (pk={pk}) validation failed: {error_msg}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
try:
device.save()
except IntegrityError as e:
transaction.set_rollback(True)
logger.exception("IntegrityError saving device pk=%s after platform assignment", pk)
messages.error(
request,
f"Error saving device (pk={pk}): {e}",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
messages.success(
request,
f"Created platform '{platform}' and assigned to device",
)
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
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:
messages.error(request, "Device is not part of a virtual chassis")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
assignments_made = 0
errors = []
counter = 1
while f"serial_{counter}" in request.POST:
serial = request.POST.get(f"serial_{counter}")
member_id = request.POST.get(f"member_id_{counter}")
if not member_id:
counter += 1
continue
try:
member = Device.objects.get(pk=member_id)
if not member.virtual_chassis or member.virtual_chassis.pk != device.virtual_chassis.pk:
errors.append(f"{member.name} is not part of the same virtual chassis")
counter += 1
continue
old_serial = member.serial
member.serial = serial
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
except Device.DoesNotExist:
errors.append(f"Device with ID {member_id} not found")
except Exception as exc: # pragma: no cover - defensive guard
errors.append(f"Error assigning serial to member {member_id}: {str(exc)}")
counter += 1
if assignments_made > 0:
messages.success(
request,
f"Successfully assigned {assignments_made} serial number(s) to VC members",
)
if errors:
for error in errors:
messages.error(request, error)
if assignments_made == 0 and not errors:
messages.info(request, "No serial assignments were made")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)
class RemoveServerMappingView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, View):
"""Remove a single server entry from the device's (or VM's) librenms_id custom field dict."""
required_object_permissions = {
"POST": [("change", Device), ("change", VirtualMachine)],
}
def _get_object(self, object_type, pk):
"""Return the Device or VirtualMachine for the given pk."""
model = VirtualMachine if object_type == "vm" else Device
return get_object_or_404(model, pk=pk), model
def _sync_url_name(self, object_type):
if object_type == "vm":
return "plugins:netbox_librenms_plugin:vm_librenms_sync"
return "plugins:netbox_librenms_plugin:device_librenms_sync"
def _normalize_librenms_mapping(self, value):
if isinstance(value, bool):
return {}
if isinstance(value, int):
return {"default": value}
if isinstance(value, str) and value.isdigit():
return {"default": int(value)}
return value if isinstance(value, dict) else {}
def post(self, request, pk):
# Scope required permissions to the specific model being modified before checking.
object_type = request.POST.get("object_type", "device")
if object_type == "virtualmachine":
object_type = "vm"
if object_type not in ("device", "vm"):
return HttpResponse(f"Invalid object_type: {object_type!r}", status=400)
target_model = VirtualMachine if object_type == "vm" else Device
self.required_object_permissions = {"POST": [("change", target_model)]}
if error := self.require_all_permissions("POST"):
return error
obj, model = self._get_object(object_type, pk)
sync_url = self._sync_url_name(object_type)
server_key = request.POST.get("server_key", "").strip()
if not server_key:
messages.error(request, "No server_key provided.")
return redirect(sync_url, pk=pk)
cf_value = self._normalize_librenms_mapping(obj.custom_field_data.get("librenms_id"))
if not isinstance(cf_value, dict) or server_key not in cf_value:
messages.warning(request, f"No mapping found for server '{server_key}'.")
return redirect(sync_url, pk=pk)
# Refuse to remove mappings for servers that are still configured in the plugin.
# Only orphaned (unconfigured) mappings may be removed via this endpoint.
# Guard both multi-server mode (servers dict) and legacy single-server mode
# (top-level librenms_url in plugin config, which implicitly defines "default")
# but only when no servers section is configured (pure legacy mode).
from django.conf import settings as django_settings
plugins_cfg = django_settings.PLUGINS_CONFIG.get("netbox_librenms_plugin", {})
configured_servers = plugins_cfg.get("servers") or {}
if not isinstance(configured_servers, dict):
configured_servers = {}
legacy_url_configured = bool(plugins_cfg.get("librenms_url"))
if server_key in configured_servers or (
legacy_url_configured and not configured_servers and server_key == "default"
):
messages.error(
request,
f"Cannot remove mapping for configured server '{server_key}'. "
"Remove the server from plugin configuration first, then retry.",
)
return redirect(sync_url, pk=pk)
with transaction.atomic():
try:
obj_locked = model.objects.select_for_update().get(pk=pk)
except model.DoesNotExist:
messages.error(request, f"{model.__name__} no longer exists.")
return redirect(sync_url, pk=pk)
cf = self._normalize_librenms_mapping(obj_locked.custom_field_data.get("librenms_id"))
# Re-check after acquiring lock; mirror the pre-transaction protection logic
_is_protected = server_key in configured_servers or (
legacy_url_configured and not configured_servers and server_key == "default"
)
if isinstance(cf, dict) and server_key in cf and not _is_protected:
del cf[server_key]
obj_locked.custom_field_data["librenms_id"] = cf if cf else None
try:
obj_locked.full_clean()
obj_locked.save()
except ValidationError as exc:
transaction.set_rollback(True)
error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc)
logger.exception(
"Validation error removing LibreNMS mapping for server %r: %s", server_key, error_msg
)
messages.error(request, f"Validation error removing LibreNMS mapping: {error_msg}")
return redirect(sync_url, pk=pk)
except Exception as exc:
transaction.set_rollback(True)
logger.exception("Unexpected error removing LibreNMS mapping for server %r", server_key)
messages.error(request, f"Unexpected error removing LibreNMS mapping: {exc}")
return redirect(sync_url, pk=pk)
messages.success(request, f"Removed LibreNMS mapping for server '{server_key}'.")
else:
messages.warning(request, f"Mapping for server '{server_key}' was already removed.")
return redirect(sync_url, pk=pk)
class ConvertLegacyLibreNMSIdView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, View):
"""
Convert a legacy bare-integer librenms_id to the server-scoped JSON dict format.
Only allowed when the NetBox serial matches the LibreNMS serial, so the
association can be verified before scoping the ID to the active server.
"""
required_object_permissions = {
"POST": [("change", Device), ("change", VirtualMachine)],
}
def _get_model_and_object(self, object_type, pk):
model = VirtualMachine if object_type == "vm" else Device
return model, get_object_or_404(model, pk=pk)
def _sync_url(self, object_type, pk):
name = "vm_librenms_sync" if object_type == "vm" else "device_librenms_sync"
return redirect(f"plugins:netbox_librenms_plugin:{name}", pk=pk)
def post(self, request, pk):
object_type = request.POST.get("object_type", "device")
if object_type == "virtualmachine":
object_type = "vm"
if object_type not in ("device", "vm"):
return HttpResponse(f"Invalid object_type: {object_type!r}", status=400)
target_model = VirtualMachine if object_type == "vm" else Device
self.required_object_permissions = {"POST": [("change", target_model)]}
if error := self.require_all_permissions("POST"):
return error
model, obj = self._get_model_and_object(object_type, pk)
server_key = self.librenms_api.server_key
# Verify the device actually has a legacy bare-int librenms_id
cf_value = obj.custom_field_data.get("librenms_id")
if isinstance(cf_value, bool):
messages.error(request, "librenms_id has an invalid boolean value; cannot convert.")
return self._sync_url(object_type, pk)
if not isinstance(cf_value, (int, str)):
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
if isinstance(cf_value, str):
if not cf_value.isdigit():
messages.error(request, "librenms_id is not a valid integer; cannot convert.")
return self._sync_url(object_type, pk)
# Verify serial match before converting
librenms_id = int(cf_value) if isinstance(cf_value, str) else cf_value
success, device_info = self.librenms_api.get_device_info(librenms_id)
if not success or not device_info:
messages.error(request, "Could not retrieve device info from LibreNMS to verify serial.")
return self._sync_url(object_type, pk)
librenms_serial = (device_info.get("serial") or "").strip()
netbox_serial = (getattr(obj, "serial", None) or "").strip()
# VMs have no serial field in NetBox; skip the serial gate for them.
is_vm = object_type == "vm"
if not is_vm and (not netbox_serial or not librenms_serial or netbox_serial != librenms_serial):
messages.error(
request,
"Serial number mismatch — cannot convert legacy ID without serial confirmation.",
)
return self._sync_url(object_type, pk)
with transaction.atomic():
try:
locked = model.objects.select_for_update().get(pk=pk)
except model.DoesNotExist:
messages.error(request, f"{model.__name__} no longer exists.")
return self._sync_url(object_type, pk)
# Re-check preconditions on the locked row (another admin may have
# changed cf_value or serial between the initial read and the lock).
locked_cf = locked.custom_field_data.get("librenms_id")
if not isinstance(locked_cf, (int, str)) or isinstance(locked_cf, bool):
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
locked_id = int(locked_cf) if isinstance(locked_cf, str) and locked_cf.isdigit() else locked_cf
if not isinstance(locked_id, int):
messages.error(request, "librenms_id changed before lock was acquired; aborting.")
return self._sync_url(object_type, pk)
locked_serial = (getattr(locked, "serial", None) or "").strip()
if locked_id != librenms_id or locked_serial != netbox_serial:
messages.error(request, "Device data changed before lock was acquired; aborting conversion.")
return self._sync_url(object_type, pk)
# Check that no other object already owns this ID (server-scoped or legacy)
match = find_by_librenms_id(model, librenms_id, server_key)
conflict = match is not None and match.pk != locked.pk
if conflict:
transaction.set_rollback(True)
messages.error(
request,
f"Another {model.__name__} already has librenms_id {librenms_id} "
f"for server '{server_key}'; cannot convert.",
)
return self._sync_url(object_type, pk)
migrated = migrate_legacy_librenms_id(locked, server_key)
if not migrated:
messages.warning(request, "librenms_id is already in the server-scoped JSON format.")
return self._sync_url(object_type, pk)
try:
locked.full_clean()
locked.save()
except ValidationError as exc:
transaction.set_rollback(True)
error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc)
messages.error(request, f"Failed to save converted librenms_id: {error_msg}")
return self._sync_url(object_type, pk)
except Exception as exc:
transaction.set_rollback(True)
logger.exception("Failed saving converted librenms_id for %s/%s", object_type, pk)
messages.error(request, f"Failed to save converted librenms_id: {exc}")
return self._sync_url(object_type, pk)
messages.success(
request,
f"Converted legacy librenms_id {librenms_id}{{'{server_key}': {librenms_id}}}.",
)
return self._sync_url(object_type, pk)

View File

@@ -0,0 +1,141 @@
from dcim.models import Device
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
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("v1v2-snmp_version") or self.request.POST.get("v3-snmp_version")
if snmp_version in ("v1", "v2c"):
return AddToLIbreSNMPV1V2
return AddToLIbreSNMPV3
def get_object(self, object_id, object_type=None):
"""
Return the Device or VirtualMachine for the given ID.
Uses object_type hint when provided to avoid PK collision ambiguity
(Device and VirtualMachine share independent PK sequences).
"""
if object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=object_id)
try:
return Device.objects.get(pk=object_id)
except Device.DoesNotExist:
return get_object_or_404(VirtualMachine, pk=object_id)
def post(self, request, object_id):
"""Add a device to LibreNMS using the submitted SNMP form."""
# Check write permission before adding device to LibreNMS
if error := self.require_write_permission():
return error
self.object = self.get_object(object_id, request.POST.get("object_type"))
form_class = self.get_form_class()
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():
# 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, 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": version,
"force_add": data.get("force_add", False),
}
if data.get("port"):
device_data["port"] = data.get("port")
if data.get("transport"):
device_data["transport"] = data.get("transport")
if data.get("port_association_mode"):
device_data["port_association_mode"] = data.get("port_association_mode")
if data.get("poller_group"):
try:
device_data["poller_group"] = int(data.get("poller_group"))
except (ValueError, TypeError):
pass
if device_data["snmp_version"] in ("v1", "v2c"):
device_data["community"] = data.get("community")
elif device_data["snmp_version"] == "v3":
device_data.update(
{
"authlevel": data.get("authlevel"),
"authname": data.get("authname"),
"authpass": data.get("authpass"),
"authalgo": data.get("authalgo"),
"cryptopass": data.get("cryptopass"),
"cryptoalgo": data.get("cryptoalgo"),
}
)
else:
messages.error(self.request, "Unknown SNMP version.")
return redirect(self.object.get_absolute_url())
success, message = self.librenms_api.add_device(device_data)
if success:
messages.success(self.request, message)
else:
messages.error(self.request, message)
return redirect(self.object.get_absolute_url())
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)
if device.site:
librenms_api = self.librenms_api
field_data = {
"field": ["location", "override_sysLocation"],
"data": [device.site.name, "1"],
}
success, message = librenms_api.update_device_field(self.librenms_id, field_data)
if success:
messages.success(
request,
f"Device location updated in LibreNMS to {device.site.name}",
)
else:
messages.error(request, f"Failed to update device location in LibreNMS: {message}")
else:
messages.warning(request, "Device has no associated site in NetBox")
return redirect("plugins:netbox_librenms_plugin:device_librenms_sync", pk=pk)

View File

@@ -0,0 +1,398 @@
from urllib.parse import quote_plus
from dcim.models import Device, Interface, MACAddress
from django.contrib import messages
from django.core.cache import cache
from django.db import transaction
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View
from virtualization.models import VirtualMachine, VMInterface
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.utils import (
convert_speed_to_kbps,
get_interface_name_field,
get_librenms_device_id,
get_librenms_sync_device,
set_librenms_device_id,
)
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
VlanAssignmentMixin,
)
class SyncInterfacesView(
LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, VlanAssignmentMixin, CacheMixin, View
):
"""Sync selected interfaces from LibreNMS into NetBox."""
def get_required_permissions_for_object_type(self, object_type):
"""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
# Read server_key from POST so we use the exact server the user was viewing
server_key = request.POST.get("server_key") or self.librenms_api.server_key
self._post_server_key = server_key
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")
redirect_url = (
reverse(url_name, kwargs={"pk": object_id})
+ f"?tab=interfaces&interface_name_field={interface_name_field}"
+ (f"&server_key={quote_plus(server_key)}" if server_key else "")
)
if selected_interfaces is None:
return redirect(redirect_url)
ports_data = self.get_cached_ports_data(request, obj, server_key)
if ports_data is None:
return redirect(redirect_url)
# 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.")
return redirect(redirect_url)
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":
return get_object_or_404(VirtualMachine, pk=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.")
return None
return selected_interfaces
def get_cached_ports_data(self, request, obj, server_key=None):
"""Return cached LibreNMS port data for the given object."""
if server_key is None:
server_key = self.librenms_api.server_key
# On VC member pages the GET tab writes ports under the resolved sync device's
# cache key. Resolve the same device here so the POST path reads the same entry.
cache_obj = obj
if isinstance(obj, Device) and not get_librenms_device_id(obj, server_key, auto_save=False):
sync_device = get_librenms_sync_device(obj, server_key=server_key)
if sync_device is not None:
cache_obj = sync_device
cached_data = cache.get(self.get_cache_key(cache_obj, "ports", server_key))
if not cached_data:
messages.warning(
request,
"No cached data found. Please refresh the data before syncing.",
)
return None
return cached_data.get("ports", [])
def sync_selected_interfaces(
self,
obj,
selected_interfaces,
ports_data,
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)
if port_name in 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):
device_selection_key = f"device_selection_{interface_name}"
selected_device_id = self.request.POST.get(device_selection_key)
if 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
interface, _ = Interface.objects.get_or_create(device=target_device, name=interface_name)
elif isinstance(obj, VirtualMachine):
interface, _ = VMInterface.objects.get_or_create(virtual_machine=obj, name=interface_name)
else:
raise ValueError("Invalid object type.")
netbox_type = None
if isinstance(obj, Device):
netbox_type = self.get_netbox_interface_type(librenms_interface)
self.update_interface_attributes(
interface,
librenms_interface,
netbox_type,
exclude_columns,
interface_name_field,
)
# Sync VLANs if not excluded
if "vlans" not in exclude_columns:
self._sync_interface_vlans(interface, librenms_interface, interface_name)
def get_netbox_interface_type(self, librenms_interface):
"""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()
mapping = speed_mapping or mappings.filter(librenms_speed__isnull=True).first()
else:
mapping = mappings.filter(librenms_speed__isnull=True).first()
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:
mac_obj = existing_mac
else:
mac_obj = MACAddress.objects.create(mac_address=ifPhysAddress)
interface.mac_addresses.add(mac_obj)
if hasattr(interface, "primary_mac_address"):
interface.primary_mac_address = mac_obj
def update_interface_attributes(
self,
interface,
librenms_interface,
netbox_type,
exclude_columns,
interface_name_field,
):
"""Update interface fields from LibreNMS data, respecting excluded columns."""
is_device_interface = isinstance(interface, Interface)
LIBRENMS_TO_NETBOX_MAPPING = {
interface_name_field: "name",
"ifType": "type",
"ifSpeed": "speed",
"ifAlias": "description",
"ifMtu": "mtu",
}
for librenms_key, netbox_key in LIBRENMS_TO_NETBOX_MAPPING.items():
if netbox_key in exclude_columns:
continue
if librenms_key == "ifSpeed":
speed = convert_speed_to_kbps(librenms_interface.get(librenms_key))
setattr(interface, netbox_key, speed)
elif librenms_key == "ifType":
if is_device_interface and hasattr(interface, netbox_key):
setattr(interface, netbox_key, netbox_type)
elif librenms_key == "ifAlias":
interface_name = librenms_interface.get(interface_name_field)
if librenms_interface.get("ifAlias") != interface_name:
setattr(interface, netbox_key, librenms_interface.get(librenms_key))
else:
setattr(interface, netbox_key, librenms_interface.get(librenms_key))
port_id = librenms_interface.get("port_id")
if port_id is not None:
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
set_librenms_device_id(interface, port_id, server_key)
if "enabled" not in exclude_columns:
admin_status = librenms_interface.get("ifAdminStatus")
interface.enabled = (
True
if admin_status is None
else (admin_status.lower() == "up" if isinstance(admin_status, str) else bool(admin_status))
)
if "mac_address" not in exclude_columns:
ifPhysAddress = librenms_interface.get("ifPhysAddress")
self.handle_mac_address(interface, ifPhysAddress)
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", []),
}
# 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":
obj = get_object_or_404(VirtualMachine, pk=object_id)
else:
return JsonResponse({"error": "Invalid object type"}, status=400)
interface_ids = request.POST.getlist("interface_ids")
if not interface_ids:
return JsonResponse({"error": "No interfaces selected for deletion"}, status=400)
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:
errors.append(
"Interface {} does not belong to this device or its virtual chassis".format(
interface.name
)
)
continue
elif interface.device_id != obj.id:
errors.append(f"Interface {interface.name} does not belong to this device")
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.delete()
deleted_count += 1
except (Interface.DoesNotExist, VMInterface.DoesNotExist):
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 or interface_id}: {str(exc)}")
continue
except Exception as exc: # pragma: no cover
return JsonResponse({"error": f"Transaction failed: {str(exc)}"}, status=500)
response_data = {
"status": "success",
"deleted_count": deleted_count,
"message": f"Successfully deleted {deleted_count} interface(s)",
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" with {len(errors)} error(s)"
return JsonResponse(response_data)

View File

@@ -0,0 +1,160 @@
from urllib.parse import quote_plus
from dcim.models import Device, Interface
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 VRF, IPAddress
from virtualization.models import VirtualMachine, VMInterface
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
class SyncIPAddressesView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, 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:
try:
return VRF.objects.get(pk=vrf_id)
except VRF.DoesNotExist:
pass
return None
def get_cached_ip_data(self, request, obj):
"""Return cached LibreNMS IP address data for the given object."""
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
cached_data = cache.get(self.get_cache_key(obj, "ip_addresses", server_key))
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":
return get_object_or_404(VirtualMachine, pk=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:
url_name = "plugins:netbox_librenms_plugin:vm_librenms_sync"
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
url = f"{reverse(url_name, args=[obj.pk])}?tab=ipaddresses"
if server_key:
url += f"&server_key={quote_plus(server_key)}"
return url
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
# Read server_key from POST so we use the exact server the user was viewing
self._post_server_key = request.POST.get("server_key") or self.librenms_api.server_key
obj = self.get_object(object_type, pk)
selected_ips = self.get_selected_ips(request)
cached_ips = self.get_cached_ip_data(request, obj)
if not cached_ips:
messages.error(request, "Cache has expired. Please refresh the IP data.")
return redirect(self.get_ip_tab_url(obj))
if not selected_ips:
messages.error(request, "No IP addresses selected for synchronization.")
return redirect(self.get_ip_tab_url(obj))
results = self.process_ip_sync(request, selected_ips, cached_ips, obj, object_type)
self.display_sync_results(request, results)
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():
for ip_address in selected_ips:
try:
ip_data = next(ip for ip in cached_ips if ip["ip_address"] == ip_address)
vrf = self.get_vrf_selection(request, ip_address)
interface = None
if ip_data.get("interface_url"):
interface_id = ip_data["interface_url"].split("/")[-2]
if object_type == "device":
interface = Interface.objects.get(id=interface_id)
else:
interface = VMInterface.objects.get(id=interface_id)
ip_with_mask = ip_data["ip_with_mask"]
existing_ip = IPAddress.objects.filter(address=ip_with_mask).first()
if existing_ip:
if existing_ip.assigned_object != interface or existing_ip.vrf != vrf:
existing_ip.assigned_object = interface
existing_ip.vrf = vrf
existing_ip.save()
results["updated"].append(ip_address)
else:
results["unchanged"].append(ip_address)
else:
IPAddress.objects.create(
address=ip_with_mask,
assigned_object=interface,
status="active",
vrf=vrf,
)
results["created"].append(ip_address)
except Exception: # pragma: no cover - defensive
results["failed"].append(ip_address)
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"]:
messages.success(request, f"Updated IP addresses: {', '.join(results['updated'])}")
if results["unchanged"]:
messages.warning(
request,
f"IP addresses already exist: {', '.join(results['unchanged'])}",
)
if results["failed"]:
messages.error(request, f"Failed to sync IP addresses: {', '.join(results['failed'])}")

View File

@@ -0,0 +1,170 @@
from collections import namedtuple
from dcim.models import Site
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect
from django_tables2 import SingleTableView
from netbox_librenms_plugin.filtersets import SiteLocationFilterSet
from netbox_librenms_plugin.tables.locations import SiteLocationSyncTable
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
class SyncSiteLocationView(LibreNMSPermissionMixin, LibreNMSAPIMixin, SingleTableView):
"""Synchronize NetBox Sites with LibreNMS locations."""
table_class = SiteLocationSyncTable
template_name = "netbox_librenms_plugin/site_location_sync.html"
filterset = SiteLocationFilterSet
COORDINATE_TOLERANCE = 0.0001
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):
return []
sync_data = [self.create_sync_data(site, librenms_locations) for site in netbox_sites]
if self.request.GET and self.filterset:
return self.filterset(self.request.GET, queryset=sync_data).qs
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(
site.latitude,
site.longitude,
matched_location.get("lat"),
matched_location.get("lng"),
)
return self.SyncData(site, matched_location, is_synced)
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
lng_match = abs(float(site_lng) - float(librenms_lng)) < self.COORDINATE_TOLERANCE
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:
messages.error(request, "No site ID provided.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
site = self.get_site_by_pk(pk)
if not site:
messages.error(request, "Site not found.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
if action == "update":
return self.update_librenms_location(request, site)
if action == "create":
return self.create_librenms_location(request, site)
messages.error(request, f"Unknown action '{action}'.")
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."""
if site.latitude is None or site.longitude is None:
messages.warning(
request,
f"Latitude and/or longitude is missing. Cannot create location '{site.name}' in LibreNMS.",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
location_data = self.build_location_data(site)
success, message = self.librenms_api.add_location(location_data)
if success:
messages.success(request, f"Location '{site.name}' created in LibreNMS successfully.")
else:
messages.error(
request,
f"Failed to create location '{site.name}' in LibreNMS: {message}",
)
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,
f"Latitude and/or longitude is missing. Cannot update location '{site.name}' in LibreNMS.",
)
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
success, librenms_locations = self.get_librenms_locations()
if not success:
messages.error(request, "Failed to retrieve LibreNMS locations.")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
matched_location = self.match_site_with_location(site, librenms_locations)
if not matched_location:
messages.error(request, f"Could not find matching location for site '{site.name}'")
return redirect("plugins:netbox_librenms_plugin:site_location_sync")
location_data = self.build_location_data(site, include_name=False)
success, message = self.librenms_api.update_location(matched_location["location"], location_data)
if success:
messages.success(request, f"Location '{site.name}' updated in LibreNMS successfully.")
else:
messages.error(
request,
f"Failed to update location '{site.name}' in LibreNMS: {message}",
)
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
return data

View File

@@ -0,0 +1,175 @@
from urllib.parse import quote_plus
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,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
NetBoxObjectPermissionMixin,
)
class SyncVLANsView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, 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
# Read server_key from POST so we use the exact server the user was viewing
self._post_server_key = request.POST.get("server_key") or self.librenms_api.server_key
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"
)
server_key = getattr(self, "_post_server_key", None) or self.librenms_api.server_key
url = reverse(url_name, kwargs={"pk": object_id}) + "?tab=vlans"
if server_key:
url += f"&server_key={quote_plus(server_key)}"
return redirect(url)
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", self._post_server_key))
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)