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 ( ' ' ) 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