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/forms.py b/netbox_librenms_plugin/forms.py index 1dbc10c87..d44db4a5f 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,159 @@ 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") + + # 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.""" + + 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") or "").strip() + cleaned_data["netbox_object"] = name + 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/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/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 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/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 16c8b6895..206b6fcbe 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -5,8 +5,12 @@ 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.db.models import Q +from django.db.models.functions import Lower from django.urls import reverse from netbox.models import NetBoxModel @@ -69,6 +73,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 +773,133 @@ 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"] + 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( + models.F("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}" + + 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/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/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/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_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/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/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(); 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..28abe060f --- /dev/null +++ b/netbox_librenms_plugin/tests/test_location_parse.py @@ -0,0 +1,290 @@ +""" +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()) + + 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 +# ============================================================================= + + +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.order_by.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.order_by.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 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/utils.py b/netbox_librenms_plugin/utils.py index 8da32ed15..1e7a29181 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,197 @@ 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)\}") + +# 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. + + 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 + + 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) + 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.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 + + 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. 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..de97f3d59 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.select_related("content_type").prefetch_related("netbox_object") + + # --- PlatformMapping views --- @@ -560,6 +570,68 @@ 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.select_related("content_type").prefetch_related("netbox_object") + 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 + template_name = "netbox_librenms_plugin/locationmapping_edit.html" + + +@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 + template_name = "netbox_librenms_plugin/locationmapping_edit.html" + + +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 ---