From edd0ffa90103393de5113ecb4ad76abde3bee1d2 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Fri, 29 May 2026 15:34:50 +0000 Subject: [PATCH 01/11] feat: add LocationMapping model and location parse settings Add a LocationMapping model that maps a LibreNMS location string to a NetBox Region/Site/Location/Rack/Tenant via a GenericForeignKey, plus two LibreNMSSettings fields (location_parse_pattern, location_parse_is_regex) for parsing the LibreNMS location string into tokens. Includes the model/form definitions, parser and mapping-resolver helpers in utils.py, and the supporting migrations. --- netbox_librenms_plugin/forms.py | 240 +++++++++++++++++- .../migrations/0011_locationmapping.py | 50 ++++ ...ttings_location_parse_is_regex_and_more.py | 22 ++ netbox_librenms_plugin/models.py | 133 ++++++++++ netbox_librenms_plugin/utils.py | 190 +++++++++++++- 5 files changed, 630 insertions(+), 5 deletions(-) create mode 100644 netbox_librenms_plugin/migrations/0011_locationmapping.py create mode 100644 netbox_librenms_plugin/migrations/0012_librenmssettings_location_parse_is_regex_and_more.py diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index 1dbc10c87..7645e54d6 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -3,9 +3,20 @@ import re from dcim.choices import InterfaceTypeChoices -from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, ModuleType, Platform, Rack, Site +from dcim.models import ( + Device, + DeviceRole, + DeviceType, + Location, + Manufacturer, + ModuleType, + Platform, + Rack, + Region, + Site, +) from django import forms -from django.db.models import Case, IntegerField, Value, When +from django.db.models import Case, IntegerField, Q, Value, When from django.http import QueryDict from django.utils.translation import gettext_lazy as _ from netbox.forms import ( @@ -14,6 +25,7 @@ NetBoxModelImportForm, ) from netbox.plugins import get_plugin_config +from tenancy.models import Tenant from utilities.forms.fields import ( CSVChoiceField, CSVModelChoiceField, @@ -28,6 +40,7 @@ InterfaceTypeMapping, InventoryIgnoreRule, LibreNMSSettings, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -162,12 +175,35 @@ class ImportSettingsForm(NetBoxModelForm): help_text="Remove domain suffix from device names during import", ) + location_parse_pattern = forms.CharField( + label="Location Parse Pattern", + max_length=255, + required=False, + strip=False, # Preserve separators that may include whitespace + widget=forms.TextInput( + attrs={ + "placeholder": "{site} - {rack}", + } + ), + help_text="How to split the LibreNMS location string into NetBox fields. " + "Leave blank to match the whole string against site and location.", + ) + + location_parse_is_regex = forms.BooleanField( + label="Use regex", + required=False, + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + help_text="Treat the pattern as a raw regular expression with named groups", + ) + class Meta: model = LibreNMSSettings fields = [ "vc_member_name_pattern", "use_sysname_default", "strip_domain_default", + "location_parse_pattern", + "location_parse_is_regex", ] def clean_vc_member_name_pattern(self): @@ -225,6 +261,67 @@ def clean_vc_member_name_pattern(self): return pattern + def clean(self): + """Validate the location parse pattern against its placeholder/regex mode.""" + super().clean() + cleaned_data = self.cleaned_data + pattern = cleaned_data.get("location_parse_pattern") + is_regex = cleaned_data.get("location_parse_is_regex") + + if not pattern: + return cleaned_data + + valid_tokens = {"region", "site", "location", "rack", "tenant"} + + if is_regex: + try: + compiled = re.compile(pattern) + except re.error as exc: + self.add_error("location_parse_pattern", f"Invalid regular expression: {exc}") + return cleaned_data + group_names = set(compiled.groupindex.keys()) + if not group_names: + self.add_error( + "location_parse_pattern", + "The regex must define at least one named group, e.g. (?P...).", + ) + invalid = group_names - valid_tokens + if invalid: + self.add_error( + "location_parse_pattern", + f"Invalid named group(s): {', '.join(sorted(invalid))}. " + f"Valid groups are: {', '.join(sorted(valid_tokens))}.", + ) + else: + found = set(re.findall(r"\{(\w+)\}", pattern)) + invalid = found - valid_tokens + if invalid: + invalid_list = ", ".join(f"{{{p}}}" for p in sorted(invalid)) + valid_list = ", ".join(f"{{{t}}}" for t in sorted(valid_tokens)) + self.add_error( + "location_parse_pattern", + f"Invalid placeholder(s): {invalid_list}. Valid options are: {valid_list}.", + ) + elif not found: + self.add_error( + "location_parse_pattern", + "The pattern must include at least one placeholder, e.g. {site}.", + ) + else: + # Detect malformed/unbalanced braces (e.g. "{location" missing + # its closing brace) by stripping out valid {token} placeholders + # and checking for any leftover brace characters. + leftover = re.sub(r"\{(\w+)\}", "", pattern) + if "{" in leftover or "}" in leftover: + valid_list = ", ".join(f"{{{t}}}" for t in sorted(valid_tokens)) + self.add_error( + "location_parse_pattern", + "Malformed placeholder: check for an unbalanced or unclosed brace. " + f"Use complete placeholders like {valid_list}.", + ) + + return cleaned_data + # Keep for backward compatibility if needed elsewhere class LibreNMSSettingsForm(ServerConfigForm): @@ -705,6 +802,145 @@ class PlatformMappingFilterForm(NetBoxModelFilterSetForm): model = PlatformMapping +class LocationMappingForm(NetBoxModelForm): + """Form for creating and editing LibreNMS location-value to NetBox-object mappings. + + The target object is stored as a generic foreign key. The form exposes one + optional selector per supported NetBox type; the selector matching the chosen + ``field_type`` is required and used to populate the generic foreign key. + """ + + region = DynamicModelChoiceField(queryset=Region.objects.all(), required=False, label="NetBox Region") + site = DynamicModelChoiceField(queryset=Site.objects.all(), required=False, label="NetBox Site") + location = DynamicModelChoiceField(queryset=Location.objects.all(), required=False, label="NetBox Location") + rack = DynamicModelChoiceField(queryset=Rack.objects.all(), required=False, label="NetBox Rack") + tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False, label="NetBox Tenant") + + class Meta: + """Meta options for LocationMappingForm.""" + + model = LocationMapping + fields = ["field_type", "librenms_value", "description"] + + def __init__(self, *args, **kwargs): + """Pre-populate the relevant object selector when editing an existing mapping.""" + super().__init__(*args, **kwargs) + obj = getattr(self.instance, "netbox_object", None) + field_type = getattr(self.instance, "field_type", None) + if obj is not None and field_type in self.fields: + self.initial[field_type] = obj.pk + + def clean(self): + """Resolve the selected object for the chosen field type and set the generic FK.""" + super().clean() + cleaned_data = self.cleaned_data + field_type = cleaned_data.get("field_type") + if not field_type: + return cleaned_data + + target = cleaned_data.get(field_type) + if not target: + self.add_error(field_type, f"Select the NetBox {field_type} this value maps to.") + return cleaned_data + + self.instance.netbox_object = target + return cleaned_data + + +class LocationMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing location mappings. + + ``netbox_object`` is the name of the target object. For ``location`` and + ``rack`` field types, supply ``parent_site`` to disambiguate names that are + not unique across sites. + """ + + field_type = CSVChoiceField( + choices=LocationMapping.FIELD_TYPE_CHOICES, + help_text="Type of NetBox object the value maps to (region, site, location, rack, tenant)", + ) + netbox_object = forms.CharField(help_text="Name of the target NetBox object") + parent_site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name="name", + required=False, + help_text="Parent site name (required for location/rack when the name is not unique)", + ) + + class Meta: + """Meta options for LocationMappingImportForm.""" + + model = LocationMapping + fields = ["field_type", "librenms_value", "description"] + + def clean(self): + """Resolve the named target object into a generic foreign key on the instance.""" + super().clean() + cleaned_data = self.cleaned_data + field_type = cleaned_data.get("field_type") + name = cleaned_data.get("netbox_object") + if not field_type or not name: + return cleaned_data + + parent_site = cleaned_data.get("parent_site") + obj = self._resolve_object(field_type, name, parent_site) + if obj is not None: + self.instance.netbox_object = obj + return cleaned_data + + def _resolve_object(self, field_type, name, parent_site): + """Look up the target NetBox object by name, scoping location/rack to a site.""" + model_map = { + "region": Region, + "site": Site, + "location": Location, + "rack": Rack, + "tenant": Tenant, + } + model = model_map.get(field_type) + if model is None: + self.add_error("field_type", f"Unknown field type '{field_type}'.") + return None + + queryset = model.objects.filter(name__iexact=name) + if field_type in ("location", "rack"): + if parent_site is not None: + if field_type == "rack": + queryset = queryset.filter(Q(site=parent_site) | Q(location__site=parent_site)) + else: + queryset = queryset.filter(site=parent_site) + + matches = list(queryset[:2]) + if not matches: + self.add_error("netbox_object", f"No {field_type} named '{name}' found.") + return None + if len(matches) > 1: + self.add_error( + "netbox_object", + f"Multiple {field_type} objects named '{name}' found; specify parent_site to disambiguate.", + ) + return None + return matches[0] + + +class LocationMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering location mappings.""" + + field_type = forms.ChoiceField( + required=False, + label="Field Type", + choices=[("", "---------")] + list(LocationMapping.FIELD_TYPE_CHOICES), + ) + librenms_value = forms.CharField(required=False, label="LibreNMS Value") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = LocationMapping + + class BaseSNMPForm(forms.Form): """ Base form with fields shared by both SNMPv1/v2c and SNMPv3 LibreNMS device forms. diff --git a/netbox_librenms_plugin/migrations/0011_locationmapping.py b/netbox_librenms_plugin/migrations/0011_locationmapping.py new file mode 100644 index 000000000..f383955b9 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0011_locationmapping.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.13 on 2026-05-29 13:40 + +import django.db.models.deletion +import netbox.models.deletion +import netbox_librenms_plugin.models +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0010_inventory_and_mapping_models"), + ] + + operations = [ + migrations.CreateModel( + name="LocationMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("field_type", models.CharField(max_length=20)), + ("librenms_value", models.CharField(max_length=255)), + ("object_id", models.PositiveBigIntegerField()), + ("description", models.TextField(blank=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to="contenttypes.contenttype" + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["field_type", "librenms_value"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0012_librenmssettings_location_parse_is_regex_and_more.py b/netbox_librenms_plugin/migrations/0012_librenmssettings_location_parse_is_regex_and_more.py new file mode 100644 index 000000000..edd866d6f --- /dev/null +++ b/netbox_librenms_plugin/migrations/0012_librenmssettings_location_parse_is_regex_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.13 on 2026-05-29 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_librenms_plugin", "0011_locationmapping"), + ] + + operations = [ + migrations.AddField( + model_name="librenmssettings", + name="location_parse_is_regex", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="librenmssettings", + name="location_parse_pattern", + field=models.CharField(blank=True, default="", max_length=255), + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index 16c8b6895..01ffbb800 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -5,6 +5,8 @@ import yaml from dcim.choices import InterfaceTypeChoices from dcim.models import DeviceType, Manufacturer, ModuleType, Platform +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -69,6 +71,23 @@ class LibreNMSSettings(models.Model): "Example: '-M{position}' results in 'switch01-M2'", ) + location_parse_pattern = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Pattern describing the structure of the LibreNMS location string. " + "Available placeholders: {region}, {site}, {location}, {rack}, {tenant}. " + "Literal text between placeholders is treated as a separator. " + "Example: '{site} - {rack}' parses 'NYC - R1' into site='NYC', rack='R1'. " + "Leave blank to match the whole location string against site and location.", + ) + + location_parse_is_regex = models.BooleanField( + default=False, + help_text="Treat the location parse pattern as a raw regular expression with " + "named groups (e.g. '(?P[^-]+)-(?P.+)') instead of placeholders", + ) + use_sysname_default = models.BooleanField( default=True, help_text="Use SNMP sysName instead of LibreNMS hostname when importing devices", @@ -752,6 +771,120 @@ def to_yaml(self): return yaml.dump(data, sort_keys=False) +# Maps LocationMapping.field_type -> (app_label, model_name) of the target NetBox object. +LOCATION_MAPPING_TARGETS = { + "region": ("dcim", "region"), + "site": ("dcim", "site"), + "location": ("dcim", "location"), + "rack": ("dcim", "rack"), + "tenant": ("tenancy", "tenant"), +} + + +class LocationMapping(FullCleanOnSaveMixin, NetBoxModel): + """Map a parsed LibreNMS location value to a NetBox organisation object. + + The LibreNMS ``location`` field is a single free-text string. Users describe + its structure with a parse pattern which yields tokens for + region/site/location/rack/tenant. When a token does not match a NetBox + object's name exactly, a LocationMapping provides an explicit alias from the + LibreNMS value to a specific NetBox object (Region, Site, Location, Rack, or + Tenant) via a generic foreign key. + + Region/Site/Tenant values are globally unique, so a single LibreNMS value + maps to exactly one object of that type. Location/Rack are scoped to a parent + site in NetBox, so the same value may map to different objects under different + sites; resolution disambiguates by the parent site parsed from the same + location string. + """ + + FIELD_TYPE_CHOICES = ( + ("region", "Region"), + ("site", "Site"), + ("location", "Location"), + ("rack", "Rack"), + ("tenant", "Tenant"), + ) + + field_type = models.CharField( + max_length=20, + choices=FIELD_TYPE_CHOICES, + help_text="Which type of NetBox object the LibreNMS value maps to", + ) + librenms_value = models.CharField( + max_length=255, + help_text="Value parsed from the LibreNMS location string (e.g. 'NYC', 'East')", + ) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="+", + ) + object_id = models.PositiveBigIntegerField() + netbox_object = GenericForeignKey("content_type", "object_id") + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Normalize the value, verify the target type, and enforce uniqueness for unscoped types.""" + super().clean() + self.librenms_value = (self.librenms_value or "").strip() + if not self.librenms_value: + raise ValidationError({"librenms_value": "This field may not be blank after normalization."}) + + expected = LOCATION_MAPPING_TARGETS.get(self.field_type) + if expected is None: + raise ValidationError({"field_type": f"Unknown field type '{self.field_type}'."}) + + if self.content_type_id: + ct = self.content_type + if (ct.app_label, ct.model) != expected: + raise ValidationError({"content_type": f"Target object must be a {self.get_field_type_display()}."}) + + # region/site/tenant names are globally unique, so the same LibreNMS value + # mapping to two different objects of the same type would be ambiguous. + # location/rack are scoped to a parent site, so duplicates are allowed and + # disambiguated at resolution time by the parent site. + if self.field_type in ("region", "site", "tenant"): + qs = LocationMapping.objects.filter( + field_type=self.field_type, + librenms_value__iexact=self.librenms_value, + ) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + { + "librenms_value": ( + f"A {self.get_field_type_display()} mapping for '{self.librenms_value}' already exists." + ) + } + ) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:locationmapping_detail", args=[self.pk]) + + class Meta: + """Meta options for LocationMapping.""" + + ordering = ["field_type", "librenms_value"] + + def __str__(self): + return f"{self.get_field_type_display()}: {self.librenms_value} -> {self.netbox_object}" + + def to_yaml(self): + data = { + "field_type": self.field_type, + "librenms_value": self.librenms_value, + "netbox_object": str(self.netbox_object) if self.netbox_object else "", + "description": self.description, + } + return yaml.dump(data, sort_keys=False) + + class CarrierAutoInstallRule(FullCleanOnSaveMixin, NetBoxModel): """ User-configurable suggestion rule for missing holder/carrier modules. diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index 8da32ed15..e0b808306 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -485,7 +485,9 @@ def find_matching_site(librenms_location: str) -> dict: """ Find exact matching NetBox site for a LibreNMS location. - Only performs exact name matching (case-insensitive). + Tries an exact, case-insensitive name match first, then falls back to a + ``LocationMapping`` (field_type='site') for when the LibreNMS value differs + from the NetBox Site name. Args: librenms_location (str): Location string from LibreNMS @@ -494,11 +496,12 @@ def find_matching_site(librenms_location: str) -> dict: 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 + - match_type (str|None): 'exact', 'mapping', or None + - confidence (float): 1.0 if found, 0.0 otherwise """ from dcim.models import Site + librenms_location = _normalise_location_value(librenms_location) if not librenms_location or librenms_location == "-": return {"found": False, "site": None, "match_type": None, "confidence": 0.0} @@ -512,9 +515,190 @@ def find_matching_site(librenms_location: str) -> dict: site = Site.objects.filter(name__iexact=librenms_location).first() return {"found": True, "site": site, "match_type": "exact", "confidence": 1.0} + # Fall back to LocationMapping for when the LibreNMS value differs from the Site name + mapped_site = resolve_location_mapping("site", librenms_location) + if mapped_site is not None: + return {"found": True, "site": mapped_site, "match_type": "mapping", "confidence": 1.0} + return {"found": False, "site": None, "match_type": None, "confidence": 0.0} +def resolve_location_mapping(field_type: str, librenms_value: str, parent_site=None): + """ + Resolve a parsed LibreNMS location value to a NetBox object via LocationMapping. + + Args: + field_type (str): One of 'region', 'site', 'location', 'rack', 'tenant'. + librenms_value (str): The value parsed from the LibreNMS location string. + parent_site (Site|None): For 'location'/'rack', restrict the match to + objects belonging to this site (NetBox scopes these to a parent site). + + Returns: + The matched NetBox object, or None if no mapping applies. + """ + if not librenms_value: + return None + + try: + from netbox_librenms_plugin.models import LocationMapping + except ImportError: + return None + + mappings = LocationMapping.objects.filter( + field_type=field_type, + librenms_value__iexact=librenms_value, + ).select_related("content_type") + + for mapping in mappings: + obj = mapping.netbox_object + if obj is None: + continue + if parent_site is not None and field_type in ("location", "rack"): + obj_site_id = _get_object_site_id(obj) + if obj_site_id != parent_site.pk: + continue + return obj + + return None + + +def _get_object_site_id(obj): + """Return the site id an object belongs to (directly or via its location).""" + site_id = getattr(obj, "site_id", None) + if site_id is None: + location = getattr(obj, "location", None) + site_id = getattr(location, "site_id", None) + return site_id + + +# Placeholder tokens supported by the LibreNMS location parse pattern. +LOCATION_PARSE_TOKENS = ("region", "site", "location", "rack", "tenant") +_LOCATION_TOKEN_RE = re.compile(r"\{(region|site|location|rack|tenant)\}") + + +def _normalise_location_value(location): + """Return the location name as a string. + + LibreNMS 26.5.0 returns the location as a relationship object + (e.g. ``{"id": 1, "location": "Site A", "lat": ..., "lng": ...}``) + rather than a flat name string. This coerces either shape to the + plain name so all downstream matching/parsing works consistently, + including for any device data cached before the API change was handled. + """ + if isinstance(location, dict): + return location.get("location") or "" + return location or "" + + +def _placeholder_pattern_to_regex(pattern: str) -> str: + """ + Convert a placeholder pattern (e.g. '{site} - {rack}') into an anchored regex. + + Literal text between placeholders is escaped and treated as a separator. + Each placeholder becomes a non-greedy named capture group. + """ + regex = "" + last_end = 0 + for match in _LOCATION_TOKEN_RE.finditer(pattern): + regex += re.escape(pattern[last_end : match.start()]) + regex += f"(?P<{match.group(1)}>.+?)" + last_end = match.end() + regex += re.escape(pattern[last_end:]) + return f"^{regex}$" + + +def parse_librenms_location(location_string: str, pattern: str, is_regex: bool = False) -> dict: + """ + Parse a LibreNMS location string into region/site/location/rack/tenant tokens. + + Args: + location_string (str): The raw LibreNMS location value. + pattern (str): Placeholder pattern (e.g. '{site} - {rack}') or, when + ``is_regex`` is True, a raw regex with named groups. + is_regex (bool): Treat ``pattern`` as a raw regular expression. + + Returns: + dict: Mapping of each supported token to its parsed value (or None). + """ + result = {name: None for name in LOCATION_PARSE_TOKENS} + if not location_string or not pattern: + return result + + try: + if is_regex: + compiled = re.compile(pattern) + match = compiled.search(location_string) + else: + compiled = re.compile(_placeholder_pattern_to_regex(pattern)) + match = compiled.match(location_string) + except re.error: + logger.warning("Invalid LibreNMS location parse pattern: %r", pattern) + return result + + if not match: + return result + + for name, value in match.groupdict().items(): + if name in result and value is not None: + result[name] = value.strip() or None + return result + + +def get_location_parse_settings(): + """Return the configured (pattern, is_regex) for parsing LibreNMS locations. + + Falls back to ("", False) — i.e. whole-string matching — if the settings + row cannot be read, so import behaviour degrades gracefully rather than + raising. + """ + try: + from netbox_librenms_plugin.models import LibreNMSSettings + + settings = LibreNMSSettings.objects.filter(pk=1).first() + except Exception: # noqa: BLE001 — optional config read; default on any failure + logger.debug("Could not read LibreNMS location parse settings; using defaults", exc_info=True) + return "", False + + if settings is None: + return "", False + return settings.location_parse_pattern or "", bool(settings.location_parse_is_regex) + + +def parse_location_for_import(location_string: str) -> dict: + """ + Parse a LibreNMS location string using the configured plugin settings. + + When no parse pattern is configured, the whole string is used for both the + site and location tokens (preserving the plugin's original behaviour). + + Args: + location_string (str): The raw LibreNMS location value. + + Returns: + dict: Mapping of each supported token to its parsed value (or None). + """ + location_string = _normalise_location_value(location_string) + pattern, is_regex = get_location_parse_settings() + whole = (location_string or "").strip() or None + if not pattern: + return { + "region": None, + "site": whole, + "location": whole, + "rack": None, + "tenant": None, + } + parsed = parse_librenms_location(location_string, pattern, is_regex) + # A global parse pattern is best-effort: if this particular location does + # not match the pattern (no site token resolved), fall back to whole-string + # matching so locations that don't fit the pattern still resolve a site. + if not parsed.get("site"): + parsed["site"] = whole + if not parsed.get("location"): + parsed["location"] = whole + return parsed + + def find_matching_platform(librenms_os: str) -> dict: """ Find matching NetBox platform for a LibreNMS OS. From ef9c9f8eea737efd7a28fda0a3361150c0aacc33 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Fri, 29 May 2026 15:35:01 +0000 Subject: [PATCH 02/11] feat: add LocationMapping CRUD views, API, tables and navigation Wire the LocationMapping model into the UI and API: list/detail/edit views, django-tables2 table, filterset, REST serializer/endpoints, URL routes, and a navigation menu item. Add a contrib seed YAML and document it in the contrib README. --- contrib/README.md | 1 + contrib/location_mappings.yaml | 53 ++++++++++++++ netbox_librenms_plugin/api/serializers.py | 18 +++++ netbox_librenms_plugin/api/urls.py | 1 + netbox_librenms_plugin/api/views.py | 13 ++++ netbox_librenms_plugin/filters.py | 15 ++++ netbox_librenms_plugin/navigation.py | 19 +++++ netbox_librenms_plugin/tables/mappings.py | 39 +++++++++++ .../locationmapping.html | 30 ++++++++ .../locationmapping_list.html | 22 ++++++ netbox_librenms_plugin/urls.py | 57 +++++++++++++++ netbox_librenms_plugin/views/__init__.py | 9 +++ netbox_librenms_plugin/views/mapping_views.py | 70 +++++++++++++++++++ 13 files changed, 347 insertions(+) create mode 100644 contrib/location_mappings.yaml create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_list.html diff --git a/contrib/README.md b/contrib/README.md index bbd841303..cfe0c5c95 100644 --- a/contrib/README.md +++ b/contrib/README.md @@ -23,6 +23,7 @@ feature in the NetBox UI. | `normalization_rules.yaml` | Regex-based string normalization applied before module type/bay lookups | | `inventory_ignore_rules.yaml` | Suppresses phantom ENTITY-MIB entries (e.g. Cisco IOS-XR IDPROM artefacts) | | `platform_mappings.yaml` | Maps LibreNMS platform strings to NetBox device platforms | +| `location_mappings.yaml` | Maps LibreNMS location values to NetBox regions, sites, locations, racks, or tenants | ## Customisation diff --git a/contrib/location_mappings.yaml b/contrib/location_mappings.yaml new file mode 100644 index 000000000..e716d1630 --- /dev/null +++ b/contrib/location_mappings.yaml @@ -0,0 +1,53 @@ +# Location Mappings — Examples +# +# Maps a LibreNMS `location` value (or a token parsed from it) to a specific +# NetBox organisation object: a Region, Site, Location, Rack, or Tenant. +# +# Used when the LibreNMS value does not match the NetBox object's name exactly. +# Matching is exact (case-insensitive) on `librenms_value`. +# +# Import via: LibreNMS → Location Mappings → Import → YAML +# +# Fields: +# field_type — one of: region, site, location, rack, tenant +# librenms_value — the value as it appears in LibreNMS (e.g. "NYC") +# netbox_object — the NetBox object's name (must already exist in NetBox) +# parent_site — (optional) restricts location/rack lookups to this site, +# since NetBox scopes locations and racks to a parent site +# description — (optional) free-text notes + +# ── Region ──────────────────────────────────────────────────────────────────── +- field_type: region + librenms_value: us-east + netbox_object: US East + description: LibreNMS region code to NetBox Region + +# ── Site ────────────────────────────────────────────────────────────────────── +- field_type: site + librenms_value: NYC + netbox_object: New York + description: LibreNMS site abbreviation to NetBox Site + +- field_type: site + librenms_value: LDN + netbox_object: London + description: LibreNMS site abbreviation to NetBox Site + +# ── Location ────────────────────────────────────────────────────────────────── +# Scope to a parent site so the same value can differ per site. +- field_type: location + librenms_value: Hall A + netbox_object: Data Hall A + parent_site: New York + +# ── Rack ────────────────────────────────────────────────────────────────────── +- field_type: rack + librenms_value: R1 + netbox_object: Rack-001 + parent_site: New York + +# ── Tenant ──────────────────────────────────────────────────────────────────── +- field_type: tenant + librenms_value: acme + netbox_object: ACME Corp + description: LibreNMS tenant code to NetBox Tenant diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index fe576d712..18e407b05 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -5,6 +5,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -112,6 +113,23 @@ class Meta: ] +class LocationMappingSerializer(NetBoxModelSerializer): + """Serialize LocationMapping model for REST API.""" + + class Meta: + """Meta options for LocationMappingSerializer.""" + + model = LocationMapping + fields = [ + "id", + "field_type", + "librenms_value", + "content_type", + "object_id", + "description", + ] + + class CarrierAutoInstallRuleSerializer(NetBoxModelSerializer): """Serialize CarrierAutoInstallRule model for REST API.""" diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index de5f82b4a..48cd4a50e 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -13,6 +13,7 @@ router.register("normalization-rules", views.NormalizationRuleViewSet) router.register("inventory-ignore-rules", views.InventoryIgnoreRuleViewSet) router.register("platform-mappings", views.PlatformMappingViewSet) +router.register("location-mappings", views.LocationMappingViewSet) router.register("carrier-auto-install-rules", views.CarrierAutoInstallRuleViewSet) urlpatterns = [ diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index a73fb8069..e590d2ddd 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -17,6 +17,7 @@ DeviceTypeMappingFilterSet, InterfaceTypeMappingFilterSet, InventoryIgnoreRuleFilterSet, + LocationMappingFilterSet, ModuleBayMappingFilterSet, ModuleTypeMappingFilterSet, NormalizationRuleFilterSet, @@ -28,6 +29,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -39,6 +41,7 @@ DeviceTypeMappingSerializer, InterfaceTypeMappingSerializer, InventoryIgnoreRuleSerializer, + LocationMappingSerializer, ModuleBayMappingSerializer, ModuleTypeMappingSerializer, NormalizationRuleSerializer, @@ -134,6 +137,16 @@ class PlatformMappingViewSet(NetBoxModelViewSet): serializer_class = PlatformMappingSerializer +class LocationMappingViewSet(NetBoxModelViewSet): + """API viewset for LocationMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = LocationMappingFilterSet + + queryset = LocationMapping.objects.select_related("content_type") + serializer_class = LocationMappingSerializer + + class CarrierAutoInstallRuleViewSet(NetBoxModelViewSet): """API viewset for CarrierAutoInstallRule CRUD operations.""" diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 1edb57773..0ce788117 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -6,6 +6,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -120,6 +121,20 @@ class Meta: fields = ["librenms_os", "description"] +class LocationMappingFilterSet(django_filters.FilterSet): + """Filter set for LocationMapping model.""" + + field_type = django_filters.CharFilter(lookup_expr="iexact") + librenms_value = django_filters.CharFilter(lookup_expr="icontains") + description = django_filters.CharFilter(lookup_expr="icontains") + + class Meta: + """Meta options for LocationMappingFilterSet.""" + + model = LocationMapping + fields = ["field_type", "librenms_value", "description"] + + class CarrierAutoInstallRuleFilterSet(django_filters.FilterSet): """Filter set for CarrierAutoInstallRule model.""" diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index 7a81a395a..1df44b171 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -134,6 +134,25 @@ ), ), ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:locationmapping_list", + link_text="Location Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:locationmapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + permissions=[PERM_CHANGE_PLUGIN], + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:locationmapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + permissions=[PERM_CHANGE_PLUGIN], + ), + ), + ), PluginMenuItem( link="plugins:netbox_librenms_plugin:inventoryignorerule_list", link_text="Inventory Ignore Rules", diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py index 1a270d144..6883674cb 100644 --- a/netbox_librenms_plugin/tables/mappings.py +++ b/netbox_librenms_plugin/tables/mappings.py @@ -7,6 +7,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -287,6 +288,44 @@ class Meta: attrs = {"class": "table table-hover table-headings table-striped"} +class LocationMappingTable(NetBoxTable): + """Table for displaying LocationMapping data.""" + + field_type = tables.Column(verbose_name="Field Type") + librenms_value = tables.Column(verbose_name="LibreNMS Value", linkify=True) + netbox_object = tables.Column(verbose_name="NetBox Object", linkify=True, orderable=False) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + def render_field_type(self, record): + """Render the human-readable field type label.""" + return record.get_field_type_display() + + class Meta: + """Meta options for LocationMappingTable.""" + + model = LocationMapping + fields = ( + "pk", + "id", + "field_type", + "librenms_value", + "netbox_object", + "description", + "actions", + ) + default_columns = ( + "pk", + "id", + "field_type", + "librenms_value", + "netbox_object", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + class CarrierAutoInstallRuleTable(NetBoxTable): """Table for displaying CarrierAutoInstallRule data.""" diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping.html new file mode 100644 index 000000000..7b96ba4e6 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping.html @@ -0,0 +1,30 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + +
Field TypeLibreNMS ValueNetBox ObjectDescription
{{ object.get_field_type_display }}{{ object.librenms_value }}{% if object.netbox_object %}{{ object.netbox_object }}{% else %}{% endif %}{{ object.description|default:"—" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_list.html new file mode 100644 index 000000000..8c0da9458 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_list.html @@ -0,0 +1,22 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Location Mappings

+

Map values parsed from the LibreNMS location string to NetBox + organisation objects (Region, Site, Location, Rack, or Tenant). During + device import, the plugin first tries an exact, case-insensitive match + against NetBox object names and only falls back to these mappings when + no exact match exists. This is useful when LibreNMS uses a value that + differs from the corresponding NetBox object name.

+

Example: LibreNMS value NYC → NetBox Site New York.

+
+ {{ block.super }} +{% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 4d7881c17..e62584711 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -5,6 +5,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -115,6 +116,15 @@ PlatformMappingEditView, PlatformMappingListView, PlatformMappingView, + LocationMappingBulkDeleteView, + LocationMappingBulkExportYAMLView, + LocationMappingBulkImportView, + LocationMappingChangeLogView, + LocationMappingCreateView, + LocationMappingDeleteView, + LocationMappingEditView, + LocationMappingListView, + LocationMappingView, RemoveServerMappingView, SaveUserPrefView, SingleCableVerifyView, @@ -786,6 +796,53 @@ PlatformMappingBulkExportYAMLView.as_view(), name="platformmapping_bulk_export_yaml", ), + # Location Mapping URLs + path( + "location-mappings/", + LocationMappingListView.as_view(), + name="locationmapping_list", + ), + path( + "location-mappings//", + LocationMappingView.as_view(), + name="locationmapping_detail", + ), + path( + "location-mappings/add/", + LocationMappingCreateView.as_view(), + name="locationmapping_add", + ), + path( + "location-mappings/import/", + LocationMappingBulkImportView.as_view(), + name="locationmapping_bulk_import", + ), + path( + "location-mappings//delete/", + LocationMappingDeleteView.as_view(), + name="locationmapping_delete", + ), + path( + "location-mappings//edit/", + LocationMappingEditView.as_view(), + name="locationmapping_edit", + ), + path( + "location-mappings//changelog/", + LocationMappingChangeLogView.as_view(), + name="locationmapping_changelog", + kwargs={"model": LocationMapping}, + ), + path( + "location-mappings/delete/", + LocationMappingBulkDeleteView.as_view(), + name="locationmapping_bulk_delete", + ), + path( + "location-mappings/export-yaml/", + LocationMappingBulkExportYAMLView.as_view(), + name="locationmapping_bulk_export_yaml", + ), # Carrier Auto-Install Rule URLs path( "carrier-auto-install-rules/", diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index 4ce30a5c5..aa68f959d 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -108,6 +108,15 @@ PlatformMappingEditView, PlatformMappingListView, PlatformMappingView, + LocationMappingBulkDeleteView, + LocationMappingBulkExportYAMLView, + LocationMappingBulkImportView, + LocationMappingChangeLogView, + LocationMappingCreateView, + LocationMappingDeleteView, + LocationMappingEditView, + LocationMappingListView, + LocationMappingView, ) from .imports.actions import AddDeviceTypeMappingView # noqa: F401 from .object_sync import ( # noqa: F401 diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 60f792360..4affdf4d0 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -8,6 +8,7 @@ DeviceTypeMappingFilterSet, InterfaceTypeMappingFilterSet, InventoryIgnoreRuleFilterSet, + LocationMappingFilterSet, ModuleBayMappingFilterSet, ModuleTypeMappingFilterSet, NormalizationRuleFilterSet, @@ -26,6 +27,9 @@ InventoryIgnoreRuleFilterForm, InventoryIgnoreRuleForm, InventoryIgnoreRuleImportForm, + LocationMappingFilterForm, + LocationMappingForm, + LocationMappingImportForm, ModuleBayMappingFilterForm, ModuleBayMappingForm, ModuleBayMappingImportForm, @@ -44,6 +48,7 @@ DeviceTypeMapping, InterfaceTypeMapping, InventoryIgnoreRule, + LocationMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule, @@ -54,6 +59,7 @@ DeviceTypeMappingTable, InterfaceTypeMappingTable, InventoryIgnoreRuleTable, + LocationMappingTable, ModuleBayMappingTable, ModuleTypeMappingTable, NormalizationRuleTable, @@ -500,6 +506,10 @@ class PlatformMappingBulkExportYAMLView(BulkExportYAMLView): queryset = PlatformMapping.objects.select_related("netbox_platform") +class LocationMappingBulkExportYAMLView(BulkExportYAMLView): + queryset = LocationMapping.objects.all() + + # --- PlatformMapping views --- @@ -560,6 +570,66 @@ class PlatformMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChange queryset = PlatformMapping.objects.all() +# --- LocationMapping views --- + + +class LocationMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): + """Provides a view for listing all LocationMapping objects.""" + + queryset = LocationMapping.objects.all() + table = LocationMappingTable + filterset = LocationMappingFilterSet + filterset_form = LocationMappingFilterForm + template_name = "netbox_librenms_plugin/locationmapping_list.html" + + +class LocationMappingCreateView(LibreNMSWritePermissionMixin, generic.ObjectEditView): + """Provides a view for creating a new LocationMapping object.""" + + queryset = LocationMapping.objects.all() + form = LocationMappingForm + + +@register_model_view(LocationMapping, "bulk_import", path="import", detail=False) +class LocationMappingBulkImportView(LibreNMSWritePermissionMixin, generic.BulkImportView): + """Provides a view for bulk importing LocationMapping objects.""" + + queryset = LocationMapping.objects.all() + model_form = LocationMappingImportForm + + +class LocationMappingView(LibreNMSPermissionMixin, generic.ObjectView): + """Provides a view for displaying details of a specific LocationMapping object.""" + + queryset = LocationMapping.objects.all() + + +class LocationMappingEditView(LibreNMSWritePermissionMixin, generic.ObjectEditView): + """Provides a view for editing a specific LocationMapping object.""" + + queryset = LocationMapping.objects.all() + form = LocationMappingForm + + +class LocationMappingDeleteView(LibreNMSWritePermissionMixin, generic.ObjectDeleteView): + """Provides a view for deleting a specific LocationMapping object.""" + + queryset = LocationMapping.objects.all() + + +class LocationMappingBulkDeleteView(LibreNMSWritePermissionMixin, generic.BulkDeleteView): + """Provides a view for deleting multiple LocationMapping objects.""" + + queryset = LocationMapping.objects.all() + table = LocationMappingTable + + +class LocationMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChangeLogView): + """Provides a view for displaying the change log of a specific LocationMapping object.""" + + queryset = LocationMapping.objects.all() + + # --- CarrierAutoInstallRule views --- From 5dc9b7dc9edb6341fb82edd313c7cb700c774cd7 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Fri, 29 May 2026 15:35:12 +0000 Subject: [PATCH 03/11] feat: integrate location parsing and mapping into device import Use the configured parse pattern (with non-destructive fallback to whole-string matching) and LocationMapping aliases when resolving the site and location during device import validation and creation. Add the Location Parsing settings card and link the matched site to its NetBox object in the import validation modal. --- .../import_utils/device_operations.py | 16 +++- .../htmx/device_validation_details.html | 4 +- .../netbox_librenms_plugin/settings.html | 78 ++++++++++++++++++- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 6fb760529..c3192fec6 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -16,6 +16,8 @@ find_matching_platform, find_matching_site, match_librenms_hardware_to_device_type, + parse_location_for_import, + resolve_location_mapping, set_librenms_device_id, ) from .cache import get_import_device_cache_key @@ -515,7 +517,9 @@ def validate_device_for_import( else: # 2. For Devices: Validate Site (required) location = libre_device.get("location", "") - site_match = find_matching_site(location) + parsed_location = parse_location_for_import(location) + site_token = parsed_location.get("site") or "" + site_match = find_matching_site(site_token) result["site"] = site_match if not site_match["found"]: @@ -881,11 +885,15 @@ def import_single_device( device_data["serial"] = serial location_name = libre_device.get("location", "") - if location_name and location_name != "-": + parsed_location = parse_location_for_import(location_name) + location_token = parsed_location.get("location") + if location_token and location_token != "-": from dcim.models import Location - # Try to find matching location within the site - location = Location.objects.filter(site=site, name__iexact=location_name).first() + # Try to find matching location within the site, then fall back to a mapping + location = Location.objects.filter(site=site, name__iexact=location_token).first() + if location is None: + location = resolve_location_mapping("location", location_token, parent_site=site) if location: device_data["location"] = location diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index ade94ebe0..7672bdaa4 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -139,12 +139,12 @@
Site {% if validation.existing_device and validation.existing_device.site %} - {{ validation.existing_device.site }} + {{ validation.existing_device.site }} {% if validation.site.site and validation.existing_device.site.pk == validation.site.site.pk %} {% endif %} {% elif validation.site.site %} - {{ validation.site.site.name }} + {{ validation.site.site.name }} {% else %} No matching site diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html index 557eacc06..5630b9f46 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html @@ -247,6 +247,74 @@

+ + +
+
+

+ + Location Parsing +

+
+
+

+ Describe the structure of the single LibreNMS location string so it can be split + into NetBox fields during import. Parsed values are matched exactly by name, then fall back + to any configured Location Mappings. +

+ +
+
+
+ + {{ import_form.location_parse_pattern }} + {% if import_form.location_parse_pattern.errors %} +
+ {{ import_form.location_parse_pattern.errors }} +
+ {% endif %} +
+
+ {{ import_form.location_parse_is_regex }} + + {{ import_form.location_parse_is_regex.help_text }} +
+
+
+ +
+
+
+

Available placeholders:

+
    +
  • {region} - NetBox Region name
  • +
  • {site} - NetBox Site name
  • +
  • {location} - NetBox Location name
  • +
  • {rack} - NetBox Rack name
  • +
  • {tenant} - NetBox Tenant name
  • +
+

Note: Literal text between placeholders is treated as a + separator. Leave the pattern blank to match the whole location string against both site and location.

+
+
+
+ +
+
+

Examples:

+
    +
  • {site} - {rack} → "NYC - R1" parses site="NYC", rack="R1"
  • +
  • {site}, {location} → "NYC, Hall A" parses site="NYC", location="Hall A"
  • +
  • Regex: (?P<site>[^-]+)-(?P<rack>.+)
  • +
+
+
+
+
@@ -299,12 +367,16 @@

const saveImportBtn = document.getElementById('save-import-btn'); const useSysnameCheckbox = document.getElementById('id_use_sysname_default'); const stripDomainCheckbox = document.getElementById('id_strip_domain_default'); + const locationPatternInput = document.getElementById('id_location_parse_pattern'); + const locationRegexCheckbox = document.getElementById('id_location_parse_is_regex'); // Store the initial values to detect changes const initialServerValue = serverSelect.value; const initialVcPatternValue = vcPatternInput.value; const initialUseSysnameValue = useSysnameCheckbox ? useSysnameCheckbox.checked : true; const initialStripDomainValue = stripDomainCheckbox ? stripDomainCheckbox.checked : false; + const initialLocationPatternValue = locationPatternInput ? locationPatternInput.value : ''; + const initialLocationRegexValue = locationRegexCheckbox ? locationRegexCheckbox.checked : false; // Enable/disable server save button based on changes function updateServerSaveButton() { @@ -319,7 +391,9 @@

const vcPatternChanged = vcPatternInput.value !== initialVcPatternValue; const useSysnameChanged = useSysnameCheckbox ? (useSysnameCheckbox.checked !== initialUseSysnameValue) : false; const stripDomainChanged = stripDomainCheckbox ? (stripDomainCheckbox.checked !== initialStripDomainValue) : false; - const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged; + const locationPatternChanged = locationPatternInput ? (locationPatternInput.value !== initialLocationPatternValue) : false; + const locationRegexChanged = locationRegexCheckbox ? (locationRegexCheckbox.checked !== initialLocationRegexValue) : false; + const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged || locationPatternChanged || locationRegexChanged; saveImportBtn.disabled = !hasChanges; saveImportBtn.classList.toggle('btn-primary', hasChanges); saveImportBtn.classList.toggle('btn-secondary', !hasChanges); @@ -330,6 +404,8 @@

vcPatternInput.addEventListener('input', updateImportSaveButton); if (useSysnameCheckbox) useSysnameCheckbox.addEventListener('change', updateImportSaveButton); if (stripDomainCheckbox) stripDomainCheckbox.addEventListener('change', updateImportSaveButton); + if (locationPatternInput) locationPatternInput.addEventListener('input', updateImportSaveButton); + if (locationRegexCheckbox) locationRegexCheckbox.addEventListener('change', updateImportSaveButton); // Initialize button states updateServerSaveButton(); From 3e6bb067622cc403cb465fc764b444fdc1ac91fa Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Fri, 29 May 2026 15:35:21 +0000 Subject: [PATCH 04/11] fix: normalize LibreNMS 26.5.0 location dict in list_devices LibreNMS 26.5.0 returns each device's location as a relationship object (id, location, lat, lng, ...) instead of a flat name string. Flatten it to the location name in list_devices so the import flow matches sites correctly, mirroring the existing get_device_info handling. --- netbox_librenms_plugin/librenms_api.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 86eba28f3..58e90e65c 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -989,6 +989,14 @@ def list_devices(self, filters=None): return False, msg or "Unexpected response format: missing 'devices' list" if not all(isinstance(item, dict) for item in devices): return False, "Unexpected response format: invalid item shape in 'devices'" + # LibreNMS 26.5.0 returns the full location relationship object + # (keys: id, location, lat, lng, timestamp, fixed_coordinates) + # instead of a flat location name string. Normalise each device's + # location to the name so downstream consumers receive a consistent value. + for device in devices: + location = device.get("location") + if isinstance(location, dict): + device["location"] = location.get("location") return True, devices # LibreNMS API v0 always returns JSON objects, so result is always From 71cc2e103a6e593a2ad768b50840abc7254eebf7 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Fri, 29 May 2026 15:35:44 +0000 Subject: [PATCH 05/11] test: add tests for location mapping and parsing Add test_location_mapping.py and test_location_parse.py covering the model, parser, settings-aware wrapper (including non-destructive fallback and dict location normalization), and form validation. Update test_utils.py for the find_matching_site LocationMapping fallback. --- .../tests/test_location_mapping.py | 250 ++++++++++++++++ .../tests/test_location_parse.py | 279 ++++++++++++++++++ netbox_librenms_plugin/tests/test_utils.py | 40 ++- 3 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 netbox_librenms_plugin/tests/test_location_mapping.py create mode 100644 netbox_librenms_plugin/tests/test_location_parse.py diff --git a/netbox_librenms_plugin/tests/test_location_mapping.py b/netbox_librenms_plugin/tests/test_location_mapping.py new file mode 100644 index 000000000..687b29b09 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_location_mapping.py @@ -0,0 +1,250 @@ +""" +Tests for the LocationMapping model, resolve_location_mapping() resolver, and +the find_matching_site() mapping fallback. + +Following the repo convention (see test_platform_mapping.py): plain pytest +classes with all DB interactions mocked — no @pytest.mark.django_db. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +# ============================================================================= +# TestLocationMappingModel +# ============================================================================= + + +class TestLocationMappingModel: + """Tests for LocationMapping model behaviour.""" + + def _make(self, field_type="site", librenms_value="NYC", description=""): + from django.db.models.base import ModelState + + from netbox_librenms_plugin.models import LocationMapping + + mapping = LocationMapping.__new__(LocationMapping) + mapping._state = ModelState() + mapping.field_type = field_type + mapping.librenms_value = librenms_value + mapping.description = description + mapping.content_type_id = None + mapping.pk = None + return mapping + + def test_str_representation(self): + """__str__ shows ': value -> object'.""" + mapping = self._make(field_type="site", librenms_value="NYC") + mapping.object_id = 1 + with patch.object( + type(mapping), + "netbox_object", + new_callable=lambda: property(lambda s: "New York"), + ): + assert str(mapping) == "Site: NYC -> New York" + + def test_clean_strips_whitespace(self): + """clean() strips leading/trailing whitespace from librenms_value.""" + # Use a parent-scoped type so no global uniqueness query runs. + mapping = self._make(field_type="location", librenms_value=" Aisle 1 ") + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert mapping.librenms_value == "Aisle 1" + + def test_clean_raises_on_blank(self): + """clean() raises ValidationError when librenms_value is blank after strip.""" + from django.core.exceptions import ValidationError + + mapping = self._make(librenms_value=" ") + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "librenms_value" in str(exc_info.value) + + def test_clean_raises_on_unknown_field_type(self): + """clean() raises ValidationError for an unknown field_type.""" + from django.core.exceptions import ValidationError + + mapping = self._make(field_type="bogus", librenms_value="NYC") + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "field_type" in str(exc_info.value) + + def test_clean_raises_on_content_type_mismatch(self): + """clean() raises when the content_type does not match the field_type.""" + from django.core.exceptions import ValidationError + + mapping = self._make(field_type="site", librenms_value="NYC") + mapping.content_type_id = 99 + ct = MagicMock(app_label="dcim", model="region") + mapping._state.fields_cache["content_type"] = ct + with pytest.raises(ValidationError) as exc_info: + with patch("netbox.models.NetBoxModel.clean"): + mapping.clean() + assert "content_type" in str(exc_info.value) + + def test_clean_accepts_matching_content_type(self): + """clean() passes when content_type matches the field_type.""" + from netbox_librenms_plugin.models import LocationMapping + + mapping = self._make(field_type="site", librenms_value="NYC") + mapping.content_type_id = 5 + mapping._state.fields_cache["content_type"] = MagicMock(app_label="dcim", model="site") + with patch("netbox.models.NetBoxModel.clean"): + with patch.object(LocationMapping, "objects") as mock_objects: + mock_objects.filter.return_value.exists.return_value = False + mapping.clean() + assert mapping.librenms_value == "NYC" + + def test_clean_enforces_uniqueness_for_site(self): + """clean() raises when a duplicate site mapping value already exists.""" + from django.core.exceptions import ValidationError + + from netbox_librenms_plugin.models import LocationMapping + + mapping = self._make(field_type="site", librenms_value="NYC") + with patch("netbox.models.NetBoxModel.clean"): + with patch.object(LocationMapping, "objects") as mock_objects: + mock_objects.filter.return_value.exists.return_value = True + with pytest.raises(ValidationError) as exc_info: + mapping.clean() + assert "librenms_value" in str(exc_info.value) + + def test_clean_allows_duplicate_for_rack(self): + """clean() does not enforce global uniqueness for parent-scoped rack mappings.""" + from netbox_librenms_plugin.models import LocationMapping + + mapping = self._make(field_type="rack", librenms_value="R1") + with patch("netbox.models.NetBoxModel.clean"): + with patch.object(LocationMapping, "objects") as mock_objects: + mapping.clean() + # Uniqueness query must not be run for rack + mock_objects.filter.assert_not_called() + + def test_get_absolute_url(self): + """get_absolute_url returns the detail URL.""" + mapping = self._make() + mapping.pk = 42 + with patch("netbox_librenms_plugin.models.reverse") as mock_reverse: + mock_reverse.return_value = "/plugins/librenms/location-mappings/42/" + url = mapping.get_absolute_url() + mock_reverse.assert_called_once_with("plugins:netbox_librenms_plugin:locationmapping_detail", args=[42]) + assert url == "/plugins/librenms/location-mappings/42/" + + def test_meta_ordering(self): + """Model Meta ordering is by field_type then librenms_value.""" + from netbox_librenms_plugin.models import LocationMapping + + assert LocationMapping._meta.ordering == ["field_type", "librenms_value"] + + def test_to_yaml(self): + """to_yaml() emits field_type, librenms_value, netbox_object, description.""" + import yaml + + mapping = self._make(field_type="site", librenms_value="NYC", description="east coast") + mapping.object_id = 1 + with patch.object( + type(mapping), + "netbox_object", + new_callable=lambda: property(lambda s: "New York"), + ): + data = yaml.safe_load(mapping.to_yaml()) + assert data == { + "field_type": "site", + "librenms_value": "NYC", + "netbox_object": "New York", + "description": "east coast", + } + + +# ============================================================================= +# TestResolveLocationMapping +# ============================================================================= + + +class TestResolveLocationMapping: + """Tests for resolve_location_mapping().""" + + def test_returns_none_for_empty_value(self): + from netbox_librenms_plugin.utils import resolve_location_mapping + + assert resolve_location_mapping("site", "") is None + assert resolve_location_mapping("site", None) is None + + def test_returns_matched_object(self): + from netbox_librenms_plugin import utils + + target = MagicMock(name="site-obj") + mapping = MagicMock(netbox_object=target) + + fake_model = MagicMock() + fake_model.objects.filter.return_value.select_related.return_value = [mapping] + + with patch.dict( + "sys.modules", + {"netbox_librenms_plugin.models": MagicMock(LocationMapping=fake_model)}, + ): + result = utils.resolve_location_mapping("site", "NYC") + assert result is target + + def test_parent_site_scoping_matches(self): + """For rack/location, only an object under the parent site is returned.""" + from netbox_librenms_plugin import utils + + parent_site = MagicMock(pk=7) + target = MagicMock() + target.site_id = 7 + mapping = MagicMock(netbox_object=target) + + fake_model = MagicMock() + fake_model.objects.filter.return_value.select_related.return_value = [mapping] + + with patch.dict( + "sys.modules", + {"netbox_librenms_plugin.models": MagicMock(LocationMapping=fake_model)}, + ): + result = utils.resolve_location_mapping("rack", "R1", parent_site=parent_site) + assert result is target + + def test_parent_site_scoping_skips_mismatch(self): + """An object under a different site is skipped.""" + from netbox_librenms_plugin import utils + + parent_site = MagicMock(pk=7) + target = MagicMock() + target.site_id = 99 + mapping = MagicMock(netbox_object=target) + + fake_model = MagicMock() + fake_model.objects.filter.return_value.select_related.return_value = [mapping] + + with patch.dict( + "sys.modules", + {"netbox_librenms_plugin.models": MagicMock(LocationMapping=fake_model)}, + ): + result = utils.resolve_location_mapping("rack", "R1", parent_site=parent_site) + assert result is None + + +# ============================================================================= +# Test_GetObjectSiteId +# ============================================================================= + + +class TestGetObjectSiteId: + """Tests for the _get_object_site_id() helper.""" + + def test_direct_site_id(self): + from netbox_librenms_plugin.utils import _get_object_site_id + + obj = MagicMock(site_id=3) + assert _get_object_site_id(obj) == 3 + + def test_via_location(self): + from netbox_librenms_plugin.utils import _get_object_site_id + + obj = MagicMock(site_id=None) + obj.location = MagicMock(site_id=8) + assert _get_object_site_id(obj) == 8 diff --git a/netbox_librenms_plugin/tests/test_location_parse.py b/netbox_librenms_plugin/tests/test_location_parse.py new file mode 100644 index 000000000..63850fc85 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_location_parse.py @@ -0,0 +1,279 @@ +""" +Tests for the LibreNMS location parse-pattern feature. + +Covers the placeholder/regex parser, the settings-aware wrapper, and the +ImportSettingsForm validation. Follows the repo convention of mocked DB access +(no @pytest.mark.django_db). +""" + +from unittest.mock import MagicMock, patch + + +# ============================================================================= +# TestParseLibrenmsLocation +# ============================================================================= + + +class TestParseLibrenmsLocation: + """Tests for parse_librenms_location().""" + + def test_empty_inputs_return_all_none(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + assert parse_librenms_location("", "{site}") == { + "region": None, + "site": None, + "location": None, + "rack": None, + "tenant": None, + } + assert parse_librenms_location("NYC", "") == { + "region": None, + "site": None, + "location": None, + "rack": None, + "tenant": None, + } + + def test_single_placeholder(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NYC", "{site}") + assert result["site"] == "NYC" + assert result["rack"] is None + + def test_two_placeholders_with_separator(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NYC - R1", "{site} - {rack}") + assert result["site"] == "NYC" + assert result["rack"] == "R1" + + def test_three_placeholders(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("East/NYC/HallA", "{region}/{site}/{location}") + assert result["region"] == "East" + assert result["site"] == "NYC" + assert result["location"] == "HallA" + + def test_values_are_stripped(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NYC , Hall A", "{site} , {location}") + assert result["site"] == "NYC" + assert result["location"] == "Hall A" + + def test_no_match_returns_all_none(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NoSeparatorHere", "{site} - {rack}") + assert all(v is None for v in result.values()) + + def test_regex_mode_with_named_groups(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NYC-R1", r"(?P[^-]+)-(?P.+)", is_regex=True) + assert result["site"] == "NYC" + assert result["rack"] == "R1" + + def test_regex_mode_search_anywhere(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("prefix site=NYC end", r"site=(?P\w+)", is_regex=True) + assert result["site"] == "NYC" + + def test_invalid_regex_returns_all_none(self): + from netbox_librenms_plugin.utils import parse_librenms_location + + result = parse_librenms_location("NYC", "(?P[", is_regex=True) + assert all(v is None for v in result.values()) + + +# ============================================================================= +# TestPlaceholderToRegex +# ============================================================================= + + +class TestPlaceholderToRegex: + """Tests for the _placeholder_pattern_to_regex() helper.""" + + def test_escapes_literals(self): + from netbox_librenms_plugin.utils import _placeholder_pattern_to_regex + + regex = _placeholder_pattern_to_regex("{site}.{rack}") + # The literal dot must be escaped, not a regex wildcard + assert r"\." in regex + assert regex.startswith("^") and regex.endswith("$") + + def test_named_groups_present(self): + from netbox_librenms_plugin.utils import _placeholder_pattern_to_regex + + regex = _placeholder_pattern_to_regex("{site} - {rack}") + assert "(?P" in regex + assert "(?P" in regex + + +# ============================================================================= +# TestParseLocationForImport +# ============================================================================= + + +class TestParseLocationForImport: + """Tests for the settings-aware parse_location_for_import() wrapper.""" + + def test_no_pattern_uses_whole_string_for_site_and_location(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "get_location_parse_settings", return_value=("", False)): + result = utils.parse_location_for_import("Some Location") + assert result["site"] == "Some Location" + assert result["location"] == "Some Location" + assert result["rack"] is None + + def test_no_pattern_empty_string(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "get_location_parse_settings", return_value=("", False)): + result = utils.parse_location_for_import("") + assert result["site"] is None + assert result["location"] is None + + def test_pattern_applied(self): + from netbox_librenms_plugin import utils + + with patch.object(utils, "get_location_parse_settings", return_value=("{site} - {rack}", False)): + result = utils.parse_location_for_import("NYC - R1") + assert result["site"] == "NYC" + assert result["rack"] == "R1" + + def test_regex_pattern_applied(self): + from netbox_librenms_plugin import utils + + with patch.object( + utils, + "get_location_parse_settings", + return_value=(r"(?P[^-]+)-(?P.+)", True), + ): + result = utils.parse_location_for_import("NYC-R1") + assert result["site"] == "NYC" + assert result["rack"] == "R1" + + def test_pattern_set_but_location_does_not_match_falls_back_to_whole_string(self): + # A global parse pattern is best-effort: locations that don't fit the + # pattern should still resolve a site from the whole string. + from netbox_librenms_plugin import utils + + with patch.object(utils, "get_location_parse_settings", return_value=("{site} - {rack}", False)): + result = utils.parse_location_for_import("PlainSiteName") + assert result["site"] == "PlainSiteName" + assert result["location"] == "PlainSiteName" + assert result["rack"] is None + + def test_dict_location_is_normalised_to_name(self): + # LibreNMS 26.5.0 returns location as a relationship object. + from netbox_librenms_plugin import utils + + with patch.object(utils, "get_location_parse_settings", return_value=("", False)): + result = utils.parse_location_for_import({"id": 1, "location": "NYC", "lat": 1.0, "lng": 2.0}) + assert result["site"] == "NYC" + assert result["location"] == "NYC" + + +# ============================================================================= +# TestGetLocationParseSettings +# ============================================================================= + + +class TestGetLocationParseSettings: + """Tests for get_location_parse_settings().""" + + def test_returns_defaults_when_no_settings_row(self): + from netbox_librenms_plugin import utils + + fake_model = MagicMock() + fake_model.objects.filter.return_value.first.return_value = None + with patch.dict( + "sys.modules", + {"netbox_librenms_plugin.models": MagicMock(LibreNMSSettings=fake_model)}, + ): + assert utils.get_location_parse_settings() == ("", False) + + def test_returns_configured_values(self): + from netbox_librenms_plugin import utils + + settings = MagicMock(location_parse_pattern="{site}", location_parse_is_regex=True) + fake_model = MagicMock() + fake_model.objects.filter.return_value.first.return_value = settings + with patch.dict( + "sys.modules", + {"netbox_librenms_plugin.models": MagicMock(LibreNMSSettings=fake_model)}, + ): + assert utils.get_location_parse_settings() == ("{site}", True) + + +# ============================================================================= +# TestImportSettingsFormLocationValidation +# ============================================================================= + + +class TestImportSettingsFormLocationValidation: + """Tests for ImportSettingsForm.clean() location pattern validation.""" + + def _run_clean(self, pattern, is_regex): + from netbox_librenms_plugin.forms import ImportSettingsForm + + form = ImportSettingsForm.__new__(ImportSettingsForm) + form.cleaned_data = { + "location_parse_pattern": pattern, + "location_parse_is_regex": is_regex, + } + form._errors = {} + errors = {} + + def fake_add_error(field, msg): + errors.setdefault(field, []).append(msg) + + form.add_error = fake_add_error + with patch("netbox.models.NetBoxModel.clean", create=True): + with patch.object(type(form).__mro__[1], "clean", lambda self: None, create=True): + form.clean() + return errors + + def test_blank_pattern_is_valid(self): + errors = self._run_clean("", False) + assert errors == {} + + def test_valid_placeholder_pattern(self): + errors = self._run_clean("{site} - {rack}", False) + assert errors == {} + + def test_invalid_placeholder_rejected(self): + errors = self._run_clean("{site} - {bogus}", False) + assert "location_parse_pattern" in errors + + def test_placeholder_with_no_tokens_rejected(self): + errors = self._run_clean("no tokens here", False) + assert "location_parse_pattern" in errors + + def test_malformed_unclosed_placeholder_rejected(self): + # e.g. "{site}, {location, {rack}" — the middle placeholder is missing + # its closing brace and must be flagged rather than silently ignored. + errors = self._run_clean("{site}, {location, {rack}", False) + assert "location_parse_pattern" in errors + + def test_valid_regex_pattern(self): + errors = self._run_clean(r"(?P[^-]+)-(?P.+)", True) + assert errors == {} + + def test_regex_with_invalid_group_rejected(self): + errors = self._run_clean(r"(?P.+)", True) + assert "location_parse_pattern" in errors + + def test_regex_with_no_named_group_rejected(self): + errors = self._run_clean(r"\w+", True) + assert "location_parse_pattern" in errors + + def test_malformed_regex_rejected(self): + errors = self._run_clean(r"(?P[", True) + assert "location_parse_pattern" in errors diff --git a/netbox_librenms_plugin/tests/test_utils.py b/netbox_librenms_plugin/tests/test_utils.py index 1dcff8d4c..c3b78268c 100644 --- a/netbox_librenms_plugin/tests/test_utils.py +++ b/netbox_librenms_plugin/tests/test_utils.py @@ -175,18 +175,54 @@ def test_find_site_for_location_exact_match(self, mock_site_model): @patch("dcim.models.Site") def test_find_site_for_location_not_found(self, mock_site_model): - """Returns None when no match.""" + """Returns None when no match and no mapping applies.""" mock_site_model.DoesNotExist = Exception mock_site_model.objects.get.side_effect = mock_site_model.DoesNotExist from netbox_librenms_plugin.utils import find_matching_site - result = find_matching_site("Unknown Location") + with patch("netbox_librenms_plugin.utils.resolve_location_mapping", return_value=None): + result = find_matching_site("Unknown Location") assert result["found"] is False assert result["site"] is None assert result["confidence"] == 0.0 + @patch("dcim.models.Site") + def test_find_site_falls_back_to_mapping(self, mock_site_model): + """Falls back to a LocationMapping when no exact Site match exists.""" + mock_site_model.DoesNotExist = Exception + mock_site_model.objects.get.side_effect = mock_site_model.DoesNotExist + mapped_site = MagicMock(id=7, name="New York") + + from netbox_librenms_plugin.utils import find_matching_site + + with patch( + "netbox_librenms_plugin.utils.resolve_location_mapping", + return_value=mapped_site, + ) as mock_resolve: + result = find_matching_site("NYC") + + mock_resolve.assert_called_once_with("site", "NYC") + assert result["found"] is True + assert result["site"] == mapped_site + assert result["match_type"] == "mapping" + assert result["confidence"] == 1.0 + + @patch("dcim.models.Site") + def test_find_site_exact_match_skips_mapping(self, mock_site_model): + """Exact Site match wins and the mapping fallback is not consulted.""" + mock_site = MagicMock(id=1, name="DC1") + mock_site_model.objects.get.return_value = mock_site + + from netbox_librenms_plugin.utils import find_matching_site + + with patch("netbox_librenms_plugin.utils.resolve_location_mapping") as mock_resolve: + result = find_matching_site("DC1") + + mock_resolve.assert_not_called() + assert result["match_type"] == "exact" + def test_find_site_for_location_empty(self): """Empty location returns None.""" from netbox_librenms_plugin.utils import find_matching_site From 39b6a552eb080a7412f71875d989442248d12b2e Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 08:54:46 +0000 Subject: [PATCH 06/11] fix: address PR review on location parsing robustness - Cap location string length (512 chars) before regex matching to mitigate polynomial-regex DoS on user-provided parse patterns (CodeQL). - Read LibreNMSSettings via order_by('pk').first() instead of hard-coded pk=1 so a settings row with any PK is honoured. - Strip the netbox_object value in LocationMappingImportForm.clean() before lookup and persist the normalised value. --- netbox_librenms_plugin/forms.py | 3 ++- .../tests/test_location_parse.py | 15 +++++++++++++-- netbox_librenms_plugin/utils.py | 9 ++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index 7645e54d6..f40658743 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -878,7 +878,8 @@ def clean(self): super().clean() cleaned_data = self.cleaned_data field_type = cleaned_data.get("field_type") - name = cleaned_data.get("netbox_object") + name = (cleaned_data.get("netbox_object") or "").strip() + cleaned_data["netbox_object"] = name if not field_type or not name: return cleaned_data diff --git a/netbox_librenms_plugin/tests/test_location_parse.py b/netbox_librenms_plugin/tests/test_location_parse.py index 63850fc85..28abe060f 100644 --- a/netbox_librenms_plugin/tests/test_location_parse.py +++ b/netbox_librenms_plugin/tests/test_location_parse.py @@ -89,6 +89,17 @@ def test_invalid_regex_returns_all_none(self): result = parse_librenms_location("NYC", "(?P[", is_regex=True) assert all(v is None for v in result.values()) + def test_input_longer_than_cap_is_truncated_before_matching(self): + from netbox_librenms_plugin.utils import ( + LOCATION_PARSE_MAX_INPUT_LEN, + parse_librenms_location, + ) + + # Build an input longer than the cap; only the first cap chars are matched. + site = "A" * (LOCATION_PARSE_MAX_INPUT_LEN + 50) + result = parse_librenms_location(site, "{site}", is_regex=False) + assert result["site"] == "A" * LOCATION_PARSE_MAX_INPUT_LEN + # ============================================================================= # TestPlaceholderToRegex @@ -192,7 +203,7 @@ def test_returns_defaults_when_no_settings_row(self): from netbox_librenms_plugin import utils fake_model = MagicMock() - fake_model.objects.filter.return_value.first.return_value = None + fake_model.objects.order_by.return_value.first.return_value = None with patch.dict( "sys.modules", {"netbox_librenms_plugin.models": MagicMock(LibreNMSSettings=fake_model)}, @@ -204,7 +215,7 @@ def test_returns_configured_values(self): settings = MagicMock(location_parse_pattern="{site}", location_parse_is_regex=True) fake_model = MagicMock() - fake_model.objects.filter.return_value.first.return_value = settings + fake_model.objects.order_by.return_value.first.return_value = settings with patch.dict( "sys.modules", {"netbox_librenms_plugin.models": MagicMock(LibreNMSSettings=fake_model)}, diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index e0b808306..1e7a29181 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -575,6 +575,10 @@ def _get_object_site_id(obj): LOCATION_PARSE_TOKENS = ("region", "site", "location", "rack", "tenant") _LOCATION_TOKEN_RE = re.compile(r"\{(region|site|location|rack|tenant)\}") +# Hard cap on the location string length fed to regex matching, to bound +# worst-case backtracking on user-provided patterns (regex DoS mitigation). +LOCATION_PARSE_MAX_INPUT_LEN = 512 + def _normalise_location_value(location): """Return the location name as a string. @@ -624,6 +628,9 @@ def parse_librenms_location(location_string: str, pattern: str, is_regex: bool = if not location_string or not pattern: return result + if len(location_string) > LOCATION_PARSE_MAX_INPUT_LEN: + location_string = location_string[:LOCATION_PARSE_MAX_INPUT_LEN] + try: if is_regex: compiled = re.compile(pattern) @@ -654,7 +661,7 @@ def get_location_parse_settings(): try: from netbox_librenms_plugin.models import LibreNMSSettings - settings = LibreNMSSettings.objects.filter(pk=1).first() + settings = LibreNMSSettings.objects.order_by("pk").first() except Exception: # noqa: BLE001 — optional config read; default on any failure logger.debug("Could not read LibreNMS location parse settings; using defaults", exc_info=True) return "", False From 6387dc03b91793c760b300c46f7d7e785a761cfd Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 09:01:11 +0000 Subject: [PATCH 07/11] feat: enforce DB-level uniqueness for unscoped LocationMapping Add a case-insensitive partial UniqueConstraint on (field_type, Lower(librenms_value)) for region/site/tenant mappings so concurrent writes cannot create ambiguous duplicates that make resolution non-deterministic. location/rack remain excluded (scoped to a parent site). The constraint's backing partial index also serves the unscoped resolution lookups. --- ...apping_uniq_locationmapping_unscoped_ci.py | 24 +++++++++++++++++++ netbox_librenms_plugin/models.py | 15 ++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 netbox_librenms_plugin/migrations/0013_locationmapping_uniq_locationmapping_unscoped_ci.py diff --git a/netbox_librenms_plugin/migrations/0013_locationmapping_uniq_locationmapping_unscoped_ci.py b/netbox_librenms_plugin/migrations/0013_locationmapping_uniq_locationmapping_unscoped_ci.py new file mode 100644 index 000000000..ee26700db --- /dev/null +++ b/netbox_librenms_plugin/migrations/0013_locationmapping_uniq_locationmapping_unscoped_ci.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.13 on 2026-06-01 08:59 + +import django.db.models.functions.text +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0012_librenmssettings_location_parse_is_regex_and_more"), + ] + + operations = [ + migrations.AddConstraint( + model_name="locationmapping", + constraint=models.UniqueConstraint( + models.F("field_type"), + django.db.models.functions.text.Lower("librenms_value"), + condition=models.Q(("field_type__in", ["region", "site", "tenant"])), + name="uniq_locationmapping_unscoped_ci", + ), + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index 01ffbb800..1ad423873 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -9,6 +9,8 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q +from django.db.models.functions import Lower from django.urls import reverse from netbox.models import NetBoxModel @@ -871,6 +873,19 @@ class Meta: """Meta options for LocationMapping.""" ordering = ["field_type", "librenms_value"] + constraints = [ + # region/site/tenant values are globally unique, so a case-insensitive + # (field_type, librenms_value) pair must be unique to keep resolution + # deterministic. location/rack are scoped to a parent site, so they are + # intentionally excluded and may legitimately repeat. The partial index + # backing this constraint also serves the unscoped resolution lookups. + models.UniqueConstraint( + "field_type", + Lower("librenms_value"), + condition=Q(field_type__in=["region", "site", "tenant"]), + name="uniq_locationmapping_unscoped_ci", + ), + ] def __str__(self): return f"{self.get_field_type_display()}: {self.librenms_value} -> {self.netbox_object}" From 8bb01cd7a0b641c923e8da1c3d869f3f7175bb45 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 10:26:27 +0000 Subject: [PATCH 08/11] refactor: address Copilot review on LocationMapping - Use models.F("field_type") in the UniqueConstraint to match the generated migration and Django's expression-constraint API. - Add select_related("content_type") to the LocationMapping list and YAML export querysets to avoid per-row content type queries, matching the other mapping list views. --- netbox_librenms_plugin/models.py | 2 +- netbox_librenms_plugin/views/mapping_views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index 1ad423873..206b6fcbe 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -880,7 +880,7 @@ class Meta: # intentionally excluded and may legitimately repeat. The partial index # backing this constraint also serves the unscoped resolution lookups. models.UniqueConstraint( - "field_type", + models.F("field_type"), Lower("librenms_value"), condition=Q(field_type__in=["region", "site", "tenant"]), name="uniq_locationmapping_unscoped_ci", diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 4affdf4d0..3eec6cb30 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -507,7 +507,7 @@ class PlatformMappingBulkExportYAMLView(BulkExportYAMLView): class LocationMappingBulkExportYAMLView(BulkExportYAMLView): - queryset = LocationMapping.objects.all() + queryset = LocationMapping.objects.select_related("content_type") # --- PlatformMapping views --- @@ -576,7 +576,7 @@ class PlatformMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChange class LocationMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all LocationMapping objects.""" - queryset = LocationMapping.objects.all() + queryset = LocationMapping.objects.select_related("content_type") table = LocationMappingTable filterset = LocationMappingFilterSet filterset_form = LocationMappingFilterForm From bef3d0aa0cb58a7b12f100f3cf25982bc22a6712 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 10:59:42 +0000 Subject: [PATCH 09/11] perf: prefetch LocationMapping GFK target to avoid N+1 select_related('content_type') only joins the ContentType row, not the GFK target object. Add prefetch_related('netbox_object') on the list and YAML export querysets so rendering netbox_object (table column + to_yaml) issues one query per content type instead of one per row. --- netbox_librenms_plugin/views/mapping_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index 3eec6cb30..f487bc819 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -507,7 +507,7 @@ class PlatformMappingBulkExportYAMLView(BulkExportYAMLView): class LocationMappingBulkExportYAMLView(BulkExportYAMLView): - queryset = LocationMapping.objects.select_related("content_type") + queryset = LocationMapping.objects.select_related("content_type").prefetch_related("netbox_object") # --- PlatformMapping views --- @@ -576,7 +576,7 @@ class PlatformMappingChangeLogView(LibreNMSPermissionMixin, generic.ObjectChange class LocationMappingListView(LibreNMSPermissionMixin, generic.ObjectListView): """Provides a view for listing all LocationMapping objects.""" - queryset = LocationMapping.objects.select_related("content_type") + queryset = LocationMapping.objects.select_related("content_type").prefetch_related("netbox_object") table = LocationMappingTable filterset = LocationMappingFilterSet filterset_form = LocationMappingFilterForm From 1e63013beb9391241e5077c13723e63262054fda Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 12:36:29 +0000 Subject: [PATCH 10/11] feat: show only the relevant target selector on LocationMapping form --- .../locationmapping_edit.html | 52 +++++++++++++++++++ netbox_librenms_plugin/views/mapping_views.py | 2 + 2 files changed, 54 insertions(+) create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_edit.html diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_edit.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_edit.html new file mode 100644 index 000000000..a7c5baed0 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/locationmapping_edit.html @@ -0,0 +1,52 @@ +{% extends 'generic/object_edit.html' %} +{% load i18n %} + +{% comment %} +Custom edit form for LocationMapping. + +The form exposes one NetBox object selector per supported field type +(region/site/location/rack/tenant). Only the selector matching the chosen +"Field Type" is relevant, so this script shows that single selector and hides +the rest, updating live as the field type changes. +{% endcomment %} + +{% block javascript %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/views/mapping_views.py b/netbox_librenms_plugin/views/mapping_views.py index f487bc819..de97f3d59 100644 --- a/netbox_librenms_plugin/views/mapping_views.py +++ b/netbox_librenms_plugin/views/mapping_views.py @@ -588,6 +588,7 @@ class LocationMappingCreateView(LibreNMSWritePermissionMixin, generic.ObjectEdit queryset = LocationMapping.objects.all() form = LocationMappingForm + template_name = "netbox_librenms_plugin/locationmapping_edit.html" @register_model_view(LocationMapping, "bulk_import", path="import", detail=False) @@ -609,6 +610,7 @@ class LocationMappingEditView(LibreNMSWritePermissionMixin, generic.ObjectEditVi queryset = LocationMapping.objects.all() form = LocationMappingForm + template_name = "netbox_librenms_plugin/locationmapping_edit.html" class LocationMappingDeleteView(LibreNMSWritePermissionMixin, generic.ObjectDeleteView): From 7347ead7e4bebc023b2559bd5b0a3586e66752f5 Mon Sep 17 00:00:00 2001 From: Andy Norwood Date: Mon, 1 Jun 2026 12:38:56 +0000 Subject: [PATCH 11/11] feat: order LocationMapping object selectors below field type --- netbox_librenms_plugin/forms.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index f40658743..d44db4a5f 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -816,6 +816,19 @@ class LocationMappingForm(NetBoxModelForm): rack = DynamicModelChoiceField(queryset=Rack.objects.all(), required=False, label="NetBox Rack") tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False, label="NetBox Tenant") + # Render the object selectors immediately after the field_type dropdown so the + # (single) visible selector appears directly below it. + field_order = [ + "field_type", + "region", + "site", + "location", + "rack", + "tenant", + "librenms_value", + "description", + ] + class Meta: """Meta options for LocationMappingForm."""