first commit
This commit is contained in:
768
netbox_librenms_plugin/utils.py
Normal file
768
netbox_librenms_plugin/utils.py
Normal file
@@ -0,0 +1,768 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from dcim.models import Device
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from netbox.config import get_config
|
||||
from netbox.plugins import get_plugin_config
|
||||
from utilities.paginator import get_paginate_count as netbox_get_paginate_count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_speed_to_kbps(speed_bps: int) -> int:
|
||||
"""
|
||||
Convert speed from bits per second to kilobits per second.
|
||||
|
||||
Args:
|
||||
speed_bps (int): Speed in bits per second.
|
||||
|
||||
Returns:
|
||||
int: Speed in kilobits per second.
|
||||
"""
|
||||
if speed_bps is None:
|
||||
return None
|
||||
return speed_bps // 1000
|
||||
|
||||
|
||||
def format_mac_address(mac_address: str) -> str:
|
||||
"""
|
||||
Validate and format MAC address string for table display.
|
||||
|
||||
Args:
|
||||
mac_address (str): The MAC address string to format.
|
||||
|
||||
Returns:
|
||||
str: The MAC address formatted as XX:XX:XX:XX:XX:XX.
|
||||
"""
|
||||
if not mac_address:
|
||||
return ""
|
||||
|
||||
mac_address = mac_address.strip().replace(":", "").replace("-", "")
|
||||
|
||||
if len(mac_address) != 12:
|
||||
return "Invalid MAC Address" # Return a message if the address is not valid
|
||||
|
||||
formatted_mac = ":".join(mac_address[i : i + 2] for i in range(0, len(mac_address), 2))
|
||||
return formatted_mac.upper()
|
||||
|
||||
|
||||
def get_virtual_chassis_member(device: Device, port_name: str) -> Device:
|
||||
"""
|
||||
Determines the likely virtual chassis member based on the device's vc_position and port name.
|
||||
|
||||
Args:
|
||||
device (Device): The NetBox device instance.
|
||||
port_name (str): The name of the port (e.g., 'Ethernet1').
|
||||
|
||||
Returns:
|
||||
Device: The virtual chassis member device corresponding to the port.
|
||||
Returns the original device if not part of a virtual chassis or if matching fails.
|
||||
"""
|
||||
if not hasattr(device, "virtual_chassis") or not device.virtual_chassis:
|
||||
return device
|
||||
|
||||
try:
|
||||
match = re.match(r"^[A-Za-z]+(\d+)", port_name)
|
||||
if not match:
|
||||
return device
|
||||
|
||||
# Get the port number and use it
|
||||
vc_position = int(match.group(1))
|
||||
return device.virtual_chassis.members.get(vc_position=vc_position)
|
||||
except (re.error, ValueError, ObjectDoesNotExist):
|
||||
return device
|
||||
|
||||
|
||||
def get_librenms_sync_device(device: Device, server_key: str = None) -> Optional[Device]:
|
||||
"""
|
||||
Determine which Virtual Chassis member should handle LibreNMS sync operations.
|
||||
|
||||
LibreNMS treats a Virtual Chassis as a single logical device, so only one member
|
||||
should have the librenms_id custom field set and be used for sync operations.
|
||||
|
||||
Priority order for selecting the sync device:
|
||||
1. Any member with librenms_id custom field set for *server_key* (highest priority).
|
||||
When *server_key* is None, matches any member that has any librenms_id set.
|
||||
2. Master device with primary IP (if master is designated)
|
||||
3. Any member with primary IP (fallback when no master or master lacks IP)
|
||||
4. Member with lowest vc_position (for error messages when no IPs configured)
|
||||
|
||||
Args:
|
||||
device (Device): Any device in the virtual chassis.
|
||||
server_key: LibreNMS server key used to resolve the correct librenms_id mapping.
|
||||
Pass None to match any member that has any librenms_id (e.g. in
|
||||
contexts where the active server is not known, such as table columns).
|
||||
|
||||
Returns:
|
||||
Optional[Device]: The device that should handle LibreNMS sync, or None if
|
||||
the device is not in a virtual chassis.
|
||||
"""
|
||||
if not hasattr(device, "virtual_chassis") or not device.virtual_chassis:
|
||||
return device
|
||||
|
||||
vc = device.virtual_chassis
|
||||
all_members = vc.members.all()
|
||||
|
||||
if server_key is not None:
|
||||
# Priority 1: Prefer member with an explicit per-server dict mapping for server_key.
|
||||
# This ensures a migrated device is preferred over one with a legacy bare-int ID.
|
||||
for member in all_members:
|
||||
raw_cf = member.cf.get("librenms_id")
|
||||
if isinstance(raw_cf, dict):
|
||||
val = raw_cf.get(server_key)
|
||||
if val is not None and not isinstance(val, bool):
|
||||
return member
|
||||
|
||||
# Priority 2 (legacy fallback): Any member whose librenms_id resolves for this server
|
||||
# (includes bare-int legacy IDs that are a universal fallback).
|
||||
for member in all_members:
|
||||
result = get_librenms_device_id(member, server_key, auto_save=False)
|
||||
if result:
|
||||
return member
|
||||
else:
|
||||
# server_key is None: match any member that has any librenms_id set (any server).
|
||||
# Used in contexts without an active server (e.g. device status table columns).
|
||||
for member in all_members:
|
||||
raw_cf = member.cf.get("librenms_id")
|
||||
if isinstance(raw_cf, dict):
|
||||
if any(v is not None and not isinstance(v, bool) for v in raw_cf.values()):
|
||||
return member
|
||||
elif raw_cf:
|
||||
return member
|
||||
|
||||
# Priority 2: Use master device if it has primary IP
|
||||
if vc.master and vc.master.primary_ip:
|
||||
return vc.master
|
||||
|
||||
# Priority 3: Find any member with primary IP
|
||||
for member in all_members:
|
||||
if member.primary_ip:
|
||||
return member
|
||||
|
||||
# Priority 4: Use member with lowest vc_position as fallback
|
||||
try:
|
||||
return min(all_members, key=lambda m: m.vc_position, default=None)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_table_paginate_count(request: HttpRequest, table_prefix: str) -> int:
|
||||
"""
|
||||
Extends Netbox pagination to support multiple tables by using table-specific prefixes
|
||||
|
||||
Args:
|
||||
request: HTTP request object
|
||||
table_prefix: Prefix for the table
|
||||
|
||||
Returns:
|
||||
int: Number of items to display per page
|
||||
"""
|
||||
config = get_config()
|
||||
if f"{table_prefix}per_page" in request.GET:
|
||||
try:
|
||||
per_page = int(request.GET.get(f"{table_prefix}per_page"))
|
||||
return min(per_page, config.MAX_PAGE_SIZE)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return netbox_get_paginate_count(request)
|
||||
|
||||
|
||||
def get_user_pref(request, path, default=None):
|
||||
"""Get a user preference value via request.user.config."""
|
||||
if hasattr(request, "user") and hasattr(request.user, "config"):
|
||||
return request.user.config.get(path, default)
|
||||
return default
|
||||
|
||||
|
||||
def save_user_pref(request, path, value):
|
||||
"""Save a user preference value via request.user.config."""
|
||||
if hasattr(request, "user") and hasattr(request.user, "config"):
|
||||
try:
|
||||
request.user.config.set(path, value, commit=True)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_naming_preferences(request) -> tuple[bool, bool]:
|
||||
"""Resolve use_sysname/strip_domain: POST/GET toggle → user pref → plugin settings.
|
||||
|
||||
This is the single source of truth for naming preference resolution,
|
||||
used by the import page, sync page, and sync action views.
|
||||
|
||||
Returns:
|
||||
(use_sysname, strip_domain) booleans.
|
||||
"""
|
||||
from netbox_librenms_plugin.models import LibreNMSSettings
|
||||
|
||||
settings = None
|
||||
_TRUTHY = frozenset({"on", "true", "1"})
|
||||
_USE_SYSNAME_KEYS = ("use-sysname-toggle", "use_sysname-toggle", "use_sysname")
|
||||
_STRIP_DOMAIN_KEYS = ("strip-domain-toggle", "strip_domain-toggle", "strip_domain")
|
||||
|
||||
def _is_truthy(val):
|
||||
return val.lower() in _TRUTHY if val is not None else False
|
||||
|
||||
# Check POST first (import form submissions), then GET (HTMX hx-include)
|
||||
_use_sysname_post = next((request.POST.get(k) for k in _USE_SYSNAME_KEYS if k in request.POST), None)
|
||||
_use_sysname_get = next((request.GET.get(k) for k in _USE_SYSNAME_KEYS if k in request.GET), None)
|
||||
|
||||
if _use_sysname_post is not None:
|
||||
use_sysname = _is_truthy(_use_sysname_post)
|
||||
elif _use_sysname_get is not None:
|
||||
use_sysname = _is_truthy(_use_sysname_get)
|
||||
else:
|
||||
pref = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname")
|
||||
if pref is not None:
|
||||
use_sysname = pref
|
||||
else:
|
||||
settings = LibreNMSSettings.objects.first()
|
||||
use_sysname = getattr(settings, "use_sysname_default", True) if settings else True
|
||||
|
||||
_strip_domain_post = next((request.POST.get(k) for k in _STRIP_DOMAIN_KEYS if k in request.POST), None)
|
||||
_strip_domain_get = next((request.GET.get(k) for k in _STRIP_DOMAIN_KEYS if k in request.GET), None)
|
||||
|
||||
if _strip_domain_post is not None:
|
||||
strip_domain = _is_truthy(_strip_domain_post)
|
||||
elif _strip_domain_get is not None:
|
||||
strip_domain = _is_truthy(_strip_domain_get)
|
||||
else:
|
||||
pref = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain")
|
||||
if pref is not None:
|
||||
strip_domain = pref
|
||||
else:
|
||||
if settings is None:
|
||||
settings = LibreNMSSettings.objects.first()
|
||||
strip_domain = getattr(settings, "strip_domain_default", False) if settings else False
|
||||
|
||||
return use_sysname, strip_domain
|
||||
|
||||
|
||||
def get_interface_name_field(request: Optional[HttpRequest] = None) -> str:
|
||||
"""
|
||||
Get interface name field with request override support.
|
||||
|
||||
Checks in order: GET/POST params, user preference, plugin config default.
|
||||
When a param is explicitly provided, persists it to user preferences.
|
||||
|
||||
Args:
|
||||
request: Optional HTTP request object that may contain override
|
||||
|
||||
Returns:
|
||||
str: Interface name field to use
|
||||
"""
|
||||
if request:
|
||||
# Explicit override from request params
|
||||
param_val = request.GET.get("interface_name_field") or request.POST.get("interface_name_field")
|
||||
if param_val:
|
||||
existing = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field")
|
||||
if param_val != existing:
|
||||
save_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field", param_val)
|
||||
return param_val
|
||||
|
||||
# Check user preference
|
||||
pref_val = get_user_pref(request, "plugins.netbox_librenms_plugin.interface_name_field")
|
||||
if pref_val:
|
||||
return pref_val
|
||||
|
||||
# Fall back to plugin config
|
||||
return get_plugin_config("netbox_librenms_plugin", "interface_name_field")
|
||||
|
||||
|
||||
def match_librenms_hardware_to_device_type(hardware_name: str) -> dict | None:
|
||||
"""
|
||||
Match LibreNMS hardware string to a NetBox DeviceType.
|
||||
|
||||
Only performs exact matching on part_number and model fields (case-insensitive).
|
||||
|
||||
Args:
|
||||
hardware_name (str): Hardware string from LibreNMS API (e.g., 'C9200L-48P-4X')
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing:
|
||||
- matched (bool): Whether a match was found
|
||||
- device_type (DeviceType|None): The matched DeviceType object
|
||||
- match_type (str|None): Always 'exact' if found, None otherwise
|
||||
"""
|
||||
from dcim.models import DeviceType
|
||||
|
||||
try:
|
||||
from netbox_librenms_plugin.models import DeviceTypeMapping
|
||||
|
||||
_has_device_type_mapping = True
|
||||
except ImportError:
|
||||
_has_device_type_mapping = False
|
||||
|
||||
if not hardware_name or hardware_name == "-":
|
||||
return {"matched": False, "device_type": None, "match_type": None}
|
||||
|
||||
# Check DeviceTypeMapping table first (when available)
|
||||
if _has_device_type_mapping:
|
||||
try:
|
||||
mapping = DeviceTypeMapping.objects.get(librenms_hardware__iexact=hardware_name)
|
||||
return {
|
||||
"matched": True,
|
||||
"device_type": mapping.netbox_device_type,
|
||||
"match_type": "mapping",
|
||||
}
|
||||
except DeviceTypeMapping.DoesNotExist:
|
||||
pass
|
||||
except DeviceTypeMapping.MultipleObjectsReturned:
|
||||
logger.warning(
|
||||
"Multiple DeviceTypeMapping entries match hardware %r — skipping mapping lookup; "
|
||||
"resolve the ambiguity by removing duplicate mappings.",
|
||||
hardware_name,
|
||||
)
|
||||
return None
|
||||
|
||||
# Try part number exact match
|
||||
try:
|
||||
device_type = DeviceType.objects.get(part_number__iexact=hardware_name)
|
||||
return {
|
||||
"matched": True,
|
||||
"device_type": device_type,
|
||||
"match_type": "exact",
|
||||
}
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
except DeviceType.MultipleObjectsReturned:
|
||||
logger.warning(
|
||||
"Multiple DeviceType entries match part_number %r — cannot auto-select; "
|
||||
"resolve the ambiguity by ensuring part numbers are unique across manufacturers.",
|
||||
hardware_name,
|
||||
)
|
||||
return None
|
||||
|
||||
# Try exact model match (case-insensitive)
|
||||
try:
|
||||
device_type = DeviceType.objects.get(model__iexact=hardware_name)
|
||||
return {"matched": True, "device_type": device_type, "match_type": "exact"}
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
except DeviceType.MultipleObjectsReturned:
|
||||
logger.warning(
|
||||
"Multiple DeviceType entries match model %r — cannot auto-select; "
|
||||
"resolve the ambiguity by ensuring model names are unique across manufacturers.",
|
||||
hardware_name,
|
||||
)
|
||||
return None
|
||||
|
||||
return {"matched": False, "device_type": None, "match_type": None}
|
||||
|
||||
|
||||
def find_matching_site(librenms_location: str) -> dict:
|
||||
"""
|
||||
Find exact matching NetBox site for a LibreNMS location.
|
||||
|
||||
Only performs exact name matching (case-insensitive).
|
||||
|
||||
Args:
|
||||
librenms_location (str): Location string from LibreNMS
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing:
|
||||
- found (bool): Whether a match was found
|
||||
- site (Site|None): The matched Site object
|
||||
- match_type (str|None): Always 'exact' if found, None otherwise
|
||||
- confidence (float): Always 1.0 if found, 0.0 otherwise
|
||||
"""
|
||||
from dcim.models import Site
|
||||
|
||||
if not librenms_location or librenms_location == "-":
|
||||
return {"found": False, "site": None, "match_type": None, "confidence": 0.0}
|
||||
|
||||
# Try case-insensitive exact match
|
||||
try:
|
||||
site = Site.objects.get(name__iexact=librenms_location)
|
||||
return {"found": True, "site": site, "match_type": "exact", "confidence": 1.0}
|
||||
except Site.DoesNotExist:
|
||||
pass
|
||||
except Site.MultipleObjectsReturned:
|
||||
site = Site.objects.filter(name__iexact=librenms_location).first()
|
||||
return {"found": True, "site": site, "match_type": "exact", "confidence": 1.0}
|
||||
|
||||
return {"found": False, "site": None, "match_type": None, "confidence": 0.0}
|
||||
|
||||
|
||||
def find_matching_platform(librenms_os: str) -> dict:
|
||||
"""
|
||||
Find exact matching NetBox platform for a LibreNMS OS.
|
||||
|
||||
Only performs exact name matching (case-insensitive).
|
||||
|
||||
Args:
|
||||
librenms_os (str): OS string from LibreNMS (e.g., 'ios', 'linux', 'junos')
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing:
|
||||
- found (bool): Whether a match was found
|
||||
- platform (Platform|None): The matched Platform object
|
||||
- match_type (str|None): Always 'exact' if found, None otherwise
|
||||
"""
|
||||
from dcim.models import Platform
|
||||
|
||||
if not librenms_os or librenms_os == "-":
|
||||
return {"found": False, "platform": None, "match_type": None}
|
||||
|
||||
# Try case-insensitive exact name match
|
||||
try:
|
||||
platform = Platform.objects.get(name__iexact=librenms_os)
|
||||
return {"found": True, "platform": platform, "match_type": "exact"}
|
||||
except Platform.DoesNotExist:
|
||||
pass
|
||||
except Platform.MultipleObjectsReturned:
|
||||
platform = Platform.objects.filter(name__iexact=librenms_os).first()
|
||||
return {"found": True, "platform": platform, "match_type": "exact"}
|
||||
|
||||
return {"found": False, "platform": None, "match_type": None}
|
||||
|
||||
|
||||
def get_vlan_sync_css_class(exists_in_netbox: bool, name_matches: bool = True) -> str:
|
||||
"""
|
||||
Determine CSS class for a VLAN row on the VLAN sync tab.
|
||||
|
||||
Used by both the server-side table renderer (LibreNMSVLANTable)
|
||||
and the client-facing verify endpoint (VerifyVlanSyncGroupView)
|
||||
to keep color logic consistent.
|
||||
|
||||
Args:
|
||||
exists_in_netbox: Whether the VLAN exists in NetBox (in the selected group or globally).
|
||||
name_matches: Whether the VLAN name in NetBox matches the LibreNMS name.
|
||||
|
||||
Returns:
|
||||
CSS class string: 'text-success', 'text-warning', or 'text-danger'.
|
||||
"""
|
||||
if not exists_in_netbox:
|
||||
return "text-danger"
|
||||
if name_matches:
|
||||
return "text-success"
|
||||
return "text-warning"
|
||||
|
||||
|
||||
# ============================================
|
||||
# Interface VLAN CSS helpers
|
||||
# ============================================
|
||||
# Shared by LibreNMSInterfaceTable (tables/interfaces.py) and
|
||||
# SingleVlanGroupVerifyView (views/object_sync/devices.py).
|
||||
|
||||
|
||||
def get_untagged_vlan_css_class(librenms_vid, netbox_vid, exists_in_netbox, missing_vlans, group_matches=True):
|
||||
"""
|
||||
Get CSS class for an untagged VLAN comparison.
|
||||
|
||||
Color logic:
|
||||
- Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync)
|
||||
- Red (text-danger): Interface missing from NetBox, or no untagged VLAN in NetBox
|
||||
- Orange (text-warning): Different untagged VLAN assigned, or same VID but different group
|
||||
- Green (text-success): Same untagged VLAN assigned in same group (match)
|
||||
|
||||
Args:
|
||||
librenms_vid: VLAN ID from LibreNMS.
|
||||
netbox_vid: VLAN ID currently assigned in NetBox (int or None).
|
||||
exists_in_netbox: Whether the interface exists in NetBox.
|
||||
missing_vlans: List of VIDs not found in any NetBox VLAN group.
|
||||
group_matches: Whether the selected VLAN group matches the NetBox VLAN's group.
|
||||
Only meaningful when VIDs match; defaults to True.
|
||||
|
||||
Returns:
|
||||
CSS class string: text-danger, text-warning, or text-success.
|
||||
"""
|
||||
if not exists_in_netbox:
|
||||
return "text-danger"
|
||||
if librenms_vid in missing_vlans:
|
||||
return "text-danger"
|
||||
if librenms_vid == netbox_vid:
|
||||
if not group_matches:
|
||||
return "text-warning"
|
||||
return "text-success"
|
||||
if netbox_vid is None:
|
||||
return "text-danger"
|
||||
return "text-warning"
|
||||
|
||||
|
||||
def get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches=True):
|
||||
"""
|
||||
Get CSS class for a tagged VLAN comparison.
|
||||
|
||||
Color logic:
|
||||
- Red (text-danger) + warning icon: VLAN not in any NetBox group (cannot sync)
|
||||
- Red (text-danger): Interface missing from NetBox, or VLAN not tagged on this interface
|
||||
- Orange (text-warning): Same VID tagged but in different VLAN group
|
||||
- Green (text-success): VLAN is tagged on this interface in same group
|
||||
|
||||
Args:
|
||||
vid: VLAN ID to check.
|
||||
netbox_tagged_vids: Set of VIDs currently tagged on the NetBox interface.
|
||||
exists_in_netbox: Whether the interface exists in NetBox.
|
||||
missing_vlans: List of VIDs not found in any NetBox VLAN group.
|
||||
group_matches: Whether the selected VLAN group matches the NetBox VLAN's group.
|
||||
Only meaningful when VIDs match; defaults to True.
|
||||
|
||||
Returns:
|
||||
CSS class string: text-danger, text-warning, or text-success.
|
||||
"""
|
||||
if not exists_in_netbox:
|
||||
return "text-danger"
|
||||
if vid in missing_vlans:
|
||||
return "text-danger"
|
||||
if vid in netbox_tagged_vids:
|
||||
if not group_matches:
|
||||
return "text-warning"
|
||||
return "text-success"
|
||||
return "text-danger"
|
||||
|
||||
|
||||
def get_missing_vlan_warning(vid, missing_vlans):
|
||||
"""Return warning icon HTML if VLAN is not found in any NetBox VLAN group."""
|
||||
if vid in missing_vlans:
|
||||
return (
|
||||
' <i class="mdi mdi-alert text-danger" '
|
||||
'title="VLAN not in NetBox\u2014use VLAN Sync first to create it"></i>'
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def check_vlan_group_matches(
|
||||
vlan_type,
|
||||
vid,
|
||||
selected_group_id,
|
||||
netbox_untagged_group_id,
|
||||
netbox_tagged_group_ids,
|
||||
netbox_untagged_vid,
|
||||
netbox_tagged_vids,
|
||||
):
|
||||
"""
|
||||
Check whether the selected VLAN group matches the NetBox VLAN's group.
|
||||
|
||||
Only relevant when VIDs match — if VIDs differ, the CSS is already
|
||||
warning/danger regardless of group.
|
||||
|
||||
Args:
|
||||
vlan_type: "U" or "T".
|
||||
vid: VLAN ID.
|
||||
selected_group_id: Group ID (int or None) the user selected.
|
||||
netbox_untagged_group_id: group_id of netbox untagged VLAN (int or None).
|
||||
netbox_tagged_group_ids: {vid: group_id} of netbox tagged VLANs.
|
||||
netbox_untagged_vid: VID of netbox untagged VLAN (int or None).
|
||||
netbox_tagged_vids: set of VIDs tagged in netbox.
|
||||
|
||||
Returns:
|
||||
bool: True if groups match (or comparison not applicable).
|
||||
"""
|
||||
if vlan_type == "U":
|
||||
if netbox_untagged_vid == vid:
|
||||
return netbox_untagged_group_id == selected_group_id
|
||||
else:
|
||||
if vid in netbox_tagged_vids:
|
||||
netbox_gid = netbox_tagged_group_ids.get(vid)
|
||||
return netbox_gid == selected_group_id
|
||||
return True
|
||||
|
||||
|
||||
def get_librenms_device_id(obj, server_key: str = "default", *, auto_save: bool = True):
|
||||
"""
|
||||
Get the LibreNMS device/port ID for a specific server from the JSON custom field.
|
||||
|
||||
Supports both the legacy integer format and the new multi-server JSON format::
|
||||
|
||||
Legacy: librenms_id = 42 → returned as universal fallback for any server_key
|
||||
New: librenms_id = {"primary": 42} → returns 42 only for server_key="primary"
|
||||
|
||||
If the stored value (or the dict entry for server_key) is a string it is
|
||||
normalised to ``int``. When *auto_save* is ``True`` (the default) the
|
||||
normalised value is written back so that subsequent DB queries can use a
|
||||
plain integer without defensive ``str()`` casting. Pass ``auto_save=False``
|
||||
in read-only contexts (e.g. table renderers) to avoid triggering unintended
|
||||
DB writes or signals.
|
||||
|
||||
Args:
|
||||
obj: NetBox object with a ``librenms_id`` custom field.
|
||||
server_key: LibreNMS server key (from plugin ``servers`` config).
|
||||
auto_save: When True (default), persist any normalised value back to the DB.
|
||||
|
||||
Returns:
|
||||
int or None
|
||||
"""
|
||||
cf_value = obj.cf.get("librenms_id")
|
||||
if cf_value is None:
|
||||
return None
|
||||
if isinstance(cf_value, int) and not isinstance(cf_value, bool):
|
||||
# Legacy bare integer — universal fallback for any server to ensure
|
||||
# devices imported before multi-server support remain discoverable.
|
||||
return cf_value if cf_value > 0 else None
|
||||
if isinstance(cf_value, str):
|
||||
# Someone stored a bare string (e.g., via NetBox UI/API) — normalise to int.
|
||||
# Treated as a legacy universal fallback for any server.
|
||||
try:
|
||||
int_id = int(cf_value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if int_id <= 0:
|
||||
return None
|
||||
if auto_save:
|
||||
obj.custom_field_data["librenms_id"] = int_id
|
||||
obj.save(update_fields=["custom_field_data"])
|
||||
return int_id
|
||||
if isinstance(cf_value, dict):
|
||||
value = cf_value.get(server_key)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
# Normalise string-stored ID inside JSON dict and write back.
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if auto_save:
|
||||
cf_value[server_key] = value
|
||||
obj.custom_field_data["librenms_id"] = cf_value
|
||||
obj.save(update_fields=["custom_field_data"])
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else None
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_librenms_device_id(obj, device_id, server_key: str = "default"):
|
||||
"""
|
||||
Set the LibreNMS device/port ID for a specific server on the JSON custom field.
|
||||
|
||||
Does NOT silently migrate legacy bare-integer values to the dict format.
|
||||
If the field contains a legacy bare integer (or a string that parses as an integer),
|
||||
a warning is logged and the write is skipped; use the migration workflow instead.
|
||||
|
||||
Args:
|
||||
obj: NetBox object with a ``librenms_id`` custom field.
|
||||
device_id: LibreNMS device ID (integer).
|
||||
server_key: LibreNMS server key (from plugin ``servers`` config).
|
||||
"""
|
||||
if isinstance(device_id, bool):
|
||||
logger.warning(
|
||||
"librenms_id device_id is a boolean (%r) on %r; not storing.",
|
||||
device_id,
|
||||
obj,
|
||||
)
|
||||
return
|
||||
cf_value = obj.custom_field_data.get("librenms_id") or {}
|
||||
if isinstance(cf_value, int) and not isinstance(cf_value, bool):
|
||||
logger.warning(
|
||||
"librenms_id on %r has legacy bare integer %r; skipping write to prevent "
|
||||
"silent migration. Use the migration workflow to convert.",
|
||||
obj,
|
||||
cf_value,
|
||||
)
|
||||
return
|
||||
elif isinstance(cf_value, str):
|
||||
try:
|
||||
int(cf_value)
|
||||
logger.warning(
|
||||
"librenms_id on %r has legacy bare integer string %r; skipping write to "
|
||||
"prevent silent migration. Use the migration workflow to convert.",
|
||||
obj,
|
||||
cf_value,
|
||||
)
|
||||
return
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"librenms_id custom field has unexpected string %r on %r; resetting to empty dict.",
|
||||
cf_value,
|
||||
obj,
|
||||
)
|
||||
cf_value = {}
|
||||
elif not isinstance(cf_value, dict):
|
||||
logger.warning(
|
||||
"librenms_id custom field has unexpected type %s on %r; resetting to empty dict.",
|
||||
type(cf_value).__name__,
|
||||
obj,
|
||||
)
|
||||
cf_value = {}
|
||||
try:
|
||||
cf_value[server_key] = int(device_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"librenms_id device_id %r is not a valid integer on %r; not storing.",
|
||||
device_id,
|
||||
obj,
|
||||
)
|
||||
return # Don't persist an invalid entry
|
||||
obj.custom_field_data["librenms_id"] = cf_value
|
||||
|
||||
|
||||
def find_by_librenms_id(model, librenms_id, server_key: str = "default"):
|
||||
"""
|
||||
Return the first object of *model* whose ``librenms_id`` JSON field contains
|
||||
*librenms_id* under *server_key*.
|
||||
|
||||
Also matches legacy records stored as a bare ``librenms_id`` integer or string
|
||||
in ``custom_field_data``—these predate multi-server support and act as a
|
||||
universal fallback for any *server_key*.
|
||||
|
||||
Args:
|
||||
model: A Django model class (Device, VirtualMachine, Interface, …).
|
||||
librenms_id: The LibreNMS device/port ID to look up.
|
||||
server_key: LibreNMS server key (from plugin ``servers`` config).
|
||||
|
||||
Returns:
|
||||
Model instance or None
|
||||
"""
|
||||
if librenms_id is None:
|
||||
return None
|
||||
if isinstance(librenms_id, bool):
|
||||
return None
|
||||
try:
|
||||
librenms_id = int(librenms_id)
|
||||
except (ValueError, TypeError):
|
||||
pass # keep original; string queries will still match string-stored IDs
|
||||
q = Q(**{f"custom_field_data__librenms_id__{server_key}": librenms_id})
|
||||
# Also match when the namespaced value was stored as a string (e.g. {"production": "42"}).
|
||||
q |= Q(**{f"custom_field_data__librenms_id__{server_key}": str(librenms_id)})
|
||||
# Always include legacy bare-integer and bare-string IDs as a universal fallback.
|
||||
# Legacy records were created before multi-server support; they should be visible
|
||||
# regardless of which server is currently active.
|
||||
q |= Q(custom_field_data__librenms_id=librenms_id)
|
||||
q |= Q(custom_field_data__librenms_id=str(librenms_id))
|
||||
return model.objects.filter(q).first()
|
||||
|
||||
|
||||
def migrate_legacy_librenms_id(obj, server_key: str = "default") -> bool:
|
||||
"""
|
||||
Migrate a legacy bare-integer ``librenms_id`` custom field to the JSON dict format,
|
||||
scoped to *server_key*.
|
||||
|
||||
Only performs the migration when the current value is a bare integer, i.e. a record
|
||||
created before the multi-server JSON refactor. The integer is assumed to belong to
|
||||
the server identified by *server_key* (the caller must verify this, e.g. by confirming
|
||||
that the LibreNMS device ID and serial number both match).
|
||||
|
||||
Does **not** call ``obj.save()`` — the caller is responsible for persisting the change.
|
||||
|
||||
Args:
|
||||
obj: NetBox object with a ``librenms_id`` custom field.
|
||||
server_key: LibreNMS server key the legacy integer should be scoped to.
|
||||
|
||||
Returns:
|
||||
True if the value was migrated, False if it was already in the correct format.
|
||||
"""
|
||||
cf_value = obj.custom_field_data.get("librenms_id")
|
||||
if isinstance(cf_value, bool):
|
||||
return False
|
||||
if isinstance(cf_value, int):
|
||||
int_value = cf_value
|
||||
elif isinstance(cf_value, str) and cf_value.isdigit():
|
||||
int_value = int(cf_value)
|
||||
else:
|
||||
return False
|
||||
obj.custom_field_data["librenms_id"] = {server_key: int_value}
|
||||
logger.info(
|
||||
"Migrated legacy librenms_id %r → {%r: %d} on %r",
|
||||
cf_value,
|
||||
server_key,
|
||||
int_value,
|
||||
obj,
|
||||
)
|
||||
return True
|
||||
Reference in New Issue
Block a user