first commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user