first commit
This commit is contained in:
67
netbox_librenms_plugin/views/__init__.py
Normal file
67
netbox_librenms_plugin/views/__init__.py
Normal 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
|
||||
13
netbox_librenms_plugin/views/base/__init__.py
Normal file
13
netbox_librenms_plugin/views/base/__init__.py
Normal 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",
|
||||
]
|
||||
576
netbox_librenms_plugin/views/base/cables_view.py
Normal file
576
netbox_librenms_plugin/views/base/cables_view.py
Normal 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})
|
||||
374
netbox_librenms_plugin/views/base/interfaces_view.py
Normal file
374
netbox_librenms_plugin/views/base/interfaces_view.py
Normal 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
|
||||
498
netbox_librenms_plugin/views/base/ip_addresses_view.py
Normal file
498
netbox_librenms_plugin/views/base/ip_addresses_view.py
Normal 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)
|
||||
547
netbox_librenms_plugin/views/base/librenms_sync_view.py
Normal file
547
netbox_librenms_plugin/views/base/librenms_sync_view.py
Normal 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,
|
||||
}
|
||||
216
netbox_librenms_plugin/views/base/vlan_table_view.py
Normal file
216
netbox_librenms_plugin/views/base/vlan_table_view.py
Normal 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
|
||||
27
netbox_librenms_plugin/views/imports/__init__.py
Normal file
27
netbox_librenms_plugin/views/imports/__init__.py
Normal 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",
|
||||
]
|
||||
1418
netbox_librenms_plugin/views/imports/actions.py
Normal file
1418
netbox_librenms_plugin/views/imports/actions.py
Normal file
File diff suppressed because it is too large
Load Diff
480
netbox_librenms_plugin/views/imports/list.py
Normal file
480
netbox_librenms_plugin/views/imports/list.py
Normal 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
|
||||
86
netbox_librenms_plugin/views/mapping_views.py
Normal file
86
netbox_librenms_plugin/views/mapping_views.py
Normal 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()
|
||||
693
netbox_librenms_plugin/views/mixins.py
Normal file
693
netbox_librenms_plugin/views/mixins.py
Normal 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,
|
||||
}
|
||||
18
netbox_librenms_plugin/views/object_sync/__init__.py
Normal file
18
netbox_librenms_plugin/views/object_sync/__init__.py
Normal 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,
|
||||
)
|
||||
395
netbox_librenms_plugin/views/object_sync/devices.py
Normal file
395
netbox_librenms_plugin/views/object_sync/devices.py
Normal 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
|
||||
77
netbox_librenms_plugin/views/object_sync/vms.py
Normal file
77
netbox_librenms_plugin/views/object_sync/vms.py
Normal 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
|
||||
194
netbox_librenms_plugin/views/settings_views.py
Normal file
194
netbox_librenms_plugin/views/settings_views.py
Normal 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>"
|
||||
)
|
||||
117
netbox_librenms_plugin/views/status_check.py
Normal file
117
netbox_librenms_plugin/views/status_check.py
Normal 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()
|
||||
0
netbox_librenms_plugin/views/sync/__init__.py
Normal file
0
netbox_librenms_plugin/views/sync/__init__.py
Normal file
237
netbox_librenms_plugin/views/sync/cables.py
Normal file
237
netbox_librenms_plugin/views/sync/cables.py
Normal 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'])}",
|
||||
)
|
||||
713
netbox_librenms_plugin/views/sync/device_fields.py
Normal file
713
netbox_librenms_plugin/views/sync/device_fields.py
Normal 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)
|
||||
141
netbox_librenms_plugin/views/sync/devices.py
Normal file
141
netbox_librenms_plugin/views/sync/devices.py
Normal 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)
|
||||
398
netbox_librenms_plugin/views/sync/interfaces.py
Normal file
398
netbox_librenms_plugin/views/sync/interfaces.py
Normal 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)
|
||||
160
netbox_librenms_plugin/views/sync/ip_addresses.py
Normal file
160
netbox_librenms_plugin/views/sync/ip_addresses.py
Normal 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'])}")
|
||||
170
netbox_librenms_plugin/views/sync/locations.py
Normal file
170
netbox_librenms_plugin/views/sync/locations.py
Normal 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
|
||||
175
netbox_librenms_plugin/views/sync/vlans.py
Normal file
175
netbox_librenms_plugin/views/sync/vlans.py
Normal 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)
|
||||
Reference in New Issue
Block a user