Ver Fonte

Extend distinct-value matching to other non-unique name and slug filters

Apply the same DISTINCT ON querysets to the device, rack, location, VLAN, VLAN group, and VDC filters whose names or slugs commonly repeat across sites or devices.
Jeremy Stretch há 5 dias atrás
pai
commit
0405536bfa

+ 21 - 11
netbox/dcim/filtersets.py

@@ -286,9 +286,10 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         distinct=False,
         distinct=False,
         label=_('Parent location (ID)'),
         label=_('Parent location (ID)'),
     )
     )
+    # Location slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     parent = django_filters.ModelMultipleChoiceFilter(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=Location.objects.all(),
+        queryset=Location.objects.order_by('slug').distinct('slug'),
         distinct=False,
         distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Parent location (slug)'),
         label=_('Parent location (slug)'),
@@ -1846,9 +1847,10 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Location (ID)'),
         label=_('Location (ID)'),
     )
     )
+    # Location slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     location = django_filters.ModelMultipleChoiceFilter(
     location = django_filters.ModelMultipleChoiceFilter(
         field_name='device__location__slug',
         field_name='device__location__slug',
-        queryset=Location.objects.all(),
+        queryset=Location.objects.order_by('slug').distinct('slug'),
         distinct=False,
         distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
@@ -1859,9 +1861,10 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Rack (ID)'),
         label=_('Rack (ID)'),
     )
     )
+    # Rack names aren't unique, so match on distinct names to avoid redundant predicates
     rack = django_filters.ModelMultipleChoiceFilter(
     rack = django_filters.ModelMultipleChoiceFilter(
         field_name='device__rack__name',
         field_name='device__rack__name',
-        queryset=Rack.objects.all(),
+        queryset=Rack.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Rack (name)'),
         label=_('Rack (name)'),
@@ -1871,9 +1874,10 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Device (ID)'),
         label=_('Device (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device__name',
         field_name='device__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Device (name)'),
         label=_('Device (name)'),
@@ -1953,9 +1957,10 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Location (ID)'),
         label=_('Location (ID)'),
     )
     )
+    # Location slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     location = django_filters.ModelMultipleChoiceFilter(
     location = django_filters.ModelMultipleChoiceFilter(
         field_name='_location__slug',
         field_name='_location__slug',
-        queryset=Location.objects.all(),
+        queryset=Location.objects.order_by('slug').distinct('slug'),
         distinct=False,
         distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
@@ -1966,9 +1971,10 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Rack (ID)'),
         label=_('Rack (ID)'),
     )
     )
+    # Rack names aren't unique, so match on distinct names to avoid redundant predicates
     rack = django_filters.ModelMultipleChoiceFilter(
     rack = django_filters.ModelMultipleChoiceFilter(
         field_name='_rack__name',
         field_name='_rack__name',
-        queryset=Rack.objects.all(),
+        queryset=Rack.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Rack (name)'),
         label=_('Rack (name)'),
@@ -1978,9 +1984,10 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Device (ID)'),
         label=_('Device (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device__name',
         field_name='device__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Device (name)'),
         label=_('Device (name)'),
@@ -2517,15 +2524,16 @@ class InterfaceFilterSet(
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
         label=_('Virtual Device Context')
         label=_('Virtual Device Context')
     )
     )
+    # VDC names and identifiers aren't unique, so match on distinct values to avoid redundant predicates
     vdc_identifier = django_filters.ModelMultipleChoiceFilter(
     vdc_identifier = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs__identifier',
         field_name='vdcs__identifier',
-        queryset=VirtualDeviceContext.objects.all(),
+        queryset=VirtualDeviceContext.objects.order_by('identifier').distinct('identifier'),
         to_field_name='identifier',
         to_field_name='identifier',
         label=_('Virtual Device Context (Identifier)')
         label=_('Virtual Device Context (Identifier)')
     )
     )
     vdc = django_filters.ModelMultipleChoiceFilter(
     vdc = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs__name',
         field_name='vdcs__name',
-        queryset=VirtualDeviceContext.objects.all(),
+        queryset=VirtualDeviceContext.objects.order_by('name').distinct('name'),
         to_field_name='name',
         to_field_name='name',
         label=_('Virtual Device Context')
         label=_('Virtual Device Context')
     )
     )
@@ -2675,9 +2683,10 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
         distinct=False,
         distinct=False,
         label=_('Installed device (ID)'),
         label=_('Installed device (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     installed_device = django_filters.ModelMultipleChoiceFilter(
     installed_device = django_filters.ModelMultipleChoiceFilter(
         field_name='installed_device__name',
         field_name='installed_device__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Installed device (name)'),
         label=_('Installed device (name)'),
@@ -2762,9 +2771,10 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
         distinct=False,
         distinct=False,
         label=_('Master (ID)'),
         label=_('Master (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     master = django_filters.ModelMultipleChoiceFilter(
     master = django_filters.ModelMultipleChoiceFilter(
         field_name='master__name',
         field_name='master__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Master (name)'),
         label=_('Master (name)'),

+ 65 - 0
netbox/dcim/tests/test_filtersets.py

@@ -61,6 +61,18 @@ class DeviceComponentFilterSetTestMixin:
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_shared_names(self):
+        # A name or slug shared by several objects should resolve to a single filter value
+        location, rack, device = Location.objects.first(), Rack.objects.first(), Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        Rack.objects.create(site=site, name=rack.name)
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        for param, value in (('location', location.slug), ('rack', rack.name), ('device', device.name)):
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertTrue(filterset.is_valid())
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
 
 
 class DeviceComponentTemplateFilterSetTestMixin:
 class DeviceComponentTemplateFilterSetTestMixin:
 
 
@@ -572,6 +584,15 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'parent': [locations[0].slug, locations[1].slug]}
         params = {'parent': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_parent_shared_slug(self):
+        # A location slug shared by several sites should resolve to a single filter value
+        location = Location.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        filterset = self.filterset({'parent': [location.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['parent']), 1)
+
     def test_ancestor(self):
     def test_ancestor(self):
         locations = Location.objects.filter(parent__isnull=True)[:2]
         locations = Location.objects.filter(parent__isnull=True)[:2]
         params = {'ancestor_id': [locations[0].pk, locations[1].pk]}
         params = {'ancestor_id': [locations[0].pk, locations[1].pk]}
@@ -3886,6 +3907,18 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'rack': [racks[0].name, racks[1].name]}
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
+    def test_shared_names(self):
+        # A name or slug shared by several objects should resolve to a single filter value
+        location, rack, device = Location.objects.first(), Rack.objects.first(), Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        Rack.objects.create(site=site, name=rack.name)
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        for param, value in (('location', location.slug), ('rack', rack.name), ('device', device.name)):
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertTrue(filterset.is_valid())
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
 
 
 class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin):
 class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
@@ -6212,6 +6245,20 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+    def test_vdc_shared_name(self):
+        # A VDC name or identifier shared by several devices should resolve to a single filter value
+        vdc = VirtualDeviceContext.objects.first()
+        VirtualDeviceContext.objects.create(
+            device=Device.objects.exclude(pk=vdc.device_id).first(),
+            name=vdc.name,
+            identifier=vdc.identifier,
+            status=vdc.status,
+        )
+        for param, value in (('vdc', vdc.name), ('vdc_identifier', vdc.identifier)):
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertTrue(filterset.is_valid())
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
     def test_vlan(self):
     def test_vlan(self):
         vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
         vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
         params = {'vlan_id': vlan.pk}
         params = {'vlan_id': vlan.pk}
@@ -7267,6 +7314,15 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'device': [devices[0].name, devices[1].name]}
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_installed_device_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'installed_device': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['installed_device']), 1)
+
 
 
 class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
 class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
@@ -7663,6 +7719,15 @@ class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'master': [masters[0].name, masters[2].name]}
         params = {'master': [masters[0].name, masters[2].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_master_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'master': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['master']), 1)
+
     def test_name(self):
     def test_name(self):
         params = {'name': ['VC 1', 'VC 2']}
         params = {'name': ['VC 1', 'VC 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 2 - 1
netbox/extras/filtersets.py

@@ -760,9 +760,10 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=Location.objects.all(),
         queryset=Location.objects.all(),
         label=_('Location'),
         label=_('Location'),
     )
     )
+    # Location slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     location = django_filters.ModelMultipleChoiceFilter(
     location = django_filters.ModelMultipleChoiceFilter(
         field_name='locations__slug',
         field_name='locations__slug',
-        queryset=Location.objects.all(),
+        queryset=Location.objects.order_by('slug').distinct('slug'),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Location (slug)'),
         label=_('Location (slug)'),
     )
     )

+ 9 - 0
netbox/extras/tests/test_filtersets.py

@@ -1294,6 +1294,15 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'location': [locations[0].slug, locations[1].slug]}
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_location_shared_slug(self):
+        # A location slug shared by several sites should resolve to a single filter value
+        location = Location.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        filterset = self.filterset({'location': [location.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['location']), 1)
+
     def test_device_type(self):
     def test_device_type(self):
         device_types = DeviceType.objects.all()[:2]
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}

+ 4 - 2
netbox/ipam/filtersets.py

@@ -397,9 +397,10 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
         to_field_name='id',
         to_field_name='id',
         label=_('VLAN Group (ID)'),
         label=_('VLAN Group (ID)'),
     )
     )
+    # VLAN group slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     vlan_group = django_filters.ModelMultipleChoiceFilter(
     vlan_group = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__group__slug',
         field_name='vlan__group__slug',
-        queryset=VLANGroup.objects.all(),
+        queryset=VLANGroup.objects.order_by('slug').distinct('slug'),
         distinct=False,
         distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('VLAN Group (slug)'),
         label=_('VLAN Group (slug)'),
@@ -1047,9 +1048,10 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         distinct=False,
         distinct=False,
         label=_('Group (ID)'),
         label=_('Group (ID)'),
     )
     )
+    # VLAN group slugs aren't unique, so match on distinct slugs to avoid redundant predicates
     group = django_filters.ModelMultipleChoiceFilter(
     group = django_filters.ModelMultipleChoiceFilter(
         field_name='group__slug',
         field_name='group__slug',
-        queryset=VLANGroup.objects.all(),
+        queryset=VLANGroup.objects.order_by('slug').distinct('slug'),
         distinct=False,
         distinct=False,
         to_field_name='slug',
         to_field_name='slug',
         label=_('Group'),
         label=_('Group'),

+ 18 - 0
netbox/ipam/tests/test_filtersets.py

@@ -890,6 +890,15 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
         params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_vlan_group_shared_slug(self):
+        # A VLAN group slug shared by several scopes should resolve to a single filter value
+        group = VLANGroup.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        VLANGroup.objects.create(scope=site, name=group.name, slug=group.slug)
+        filterset = self.filterset({'vlan_group': [group.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['vlan_group']), 1)
+
     def test_vlan(self):
     def test_vlan(self):
         vlans = VLAN.objects.all()[:2]
         vlans = VLAN.objects.all()[:2]
         params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
         params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
@@ -2407,6 +2416,15 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'group': [groups[0].slug, groups[1].slug]}
         params = {'group': [groups[0].slug, groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_group_shared_slug(self):
+        # A VLAN group slug shared by several scopes should resolve to a single filter value
+        group = VLANGroup.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        VLANGroup.objects.create(scope=site, name=group.name, slug=group.slug)
+        filterset = self.filterset({'group': [group.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['group']), 1)
+
     def test_description(self):
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 2 - 1
netbox/virtualization/filtersets.py

@@ -204,9 +204,10 @@ class VirtualMachineFilterSet(
         distinct=False,
         distinct=False,
         label=_('Device (ID)'),
         label=_('Device (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device__name',
         field_name='device__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         distinct=False,
         distinct=False,
         to_field_name='name',
         to_field_name='name',
         label=_('Device'),
         label=_('Device'),

+ 9 - 0
netbox/virtualization/tests/test_filtersets.py

@@ -601,6 +601,15 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'device': [devices[0].name, devices[1].name]}
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_device_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'device': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['device']), 1)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}

+ 4 - 2
netbox/vpn/filtersets.py

@@ -417,9 +417,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         field_name='pk',
         field_name='pk',
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
+    # Device names aren't unique, so match on distinct names to avoid redundant predicates
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='interface__device__name',
         field_name='interface__device__name',
-        queryset=Device.objects.all(),
+        queryset=Device.objects.order_by('name').distinct('name'),
         to_field_name='name',
         to_field_name='name',
         label=_('Device (name)'),
         label=_('Device (name)'),
     )
     )
@@ -462,9 +463,10 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         label=_('VM Interface (ID)'),
         label=_('VM Interface (ID)'),
     )
     )
+    # VLAN names aren't unique, so match on distinct names to avoid redundant predicates
     vlan = django_filters.ModelMultipleChoiceFilter(
     vlan = django_filters.ModelMultipleChoiceFilter(
         field_name='vlan__name',
         field_name='vlan__name',
-        queryset=VLAN.objects.all(),
+        queryset=VLAN.objects.order_by('name').distinct('name'),
         to_field_name='name',
         to_field_name='name',
         label=_('VLAN (name)'),
         label=_('VLAN (name)'),
     )
     )

+ 17 - 0
netbox/vpn/tests/test_filtersets.py

@@ -954,6 +954,14 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_vlan_shared_name(self):
+        # A VLAN name shared by several groups should resolve to a single filter value
+        vlan = VLAN.objects.first()
+        VLAN.objects.create(vid=vlan.vid, name=vlan.name)
+        filterset = self.filterset({'vlan': [vlan.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['vlan']), 1)
+
     def test_site(self):
     def test_site(self):
         site = Site.objects.all().first()
         site = Site.objects.all().first()
         params = {'site_id': [site.pk]}
         params = {'site_id': [site.pk]}
@@ -968,6 +976,15 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'device': ['Device 1']}
         params = {'device': ['Device 1']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+    def test_device_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'device': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['device']), 1)
+
     def test_virtual_machine(self):
     def test_virtual_machine(self):
         virtual_machine = VirtualMachine.objects.all().first()
         virtual_machine = VirtualMachine.objects.all().first()
         params = {'virtual_machine_id': [virtual_machine.pk]}
         params = {'virtual_machine_id': [virtual_machine.pk]}