Răsfoiți Sursa

Merge branch 'develop' into feature

jeremystretch 4 ani în urmă
părinte
comite
f16c6d81cf

+ 1 - 1
docs/administration/housekeeping.md

@@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
 This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
 
 ```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
 ```
 
 !!! note

+ 1 - 1
docs/installation/3-netbox.md

@@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
 A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
 
 ```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
 ```
 
 See the [housekeeping documentation](../administration/housekeeping.md) for further details.

+ 1 - 1
docs/installation/upgrading.md

@@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
 If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
 
 ```shell
-ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
+sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
 ```
 
 See the [housekeeping documentation](../administration/housekeeping.md) for further details.

+ 22 - 0
docs/release-notes/version-3.0.md

@@ -2,6 +2,28 @@
 
 ## v3.0.11 (FUTURE)
 
+### Enhancements
+
+* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
+* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
+* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
+* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
+* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
+* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
+
+### Bug Fixes
+
+* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
+* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
+* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
+* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
+* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
+* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
+* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
+* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
+* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
+* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
+
 ---
 
 ## v3.0.10 (2021-11-12)

+ 1 - 1
netbox/dcim/api/nested_serializers.py

@@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
 
     class Meta:
         model = models.VirtualChassis
-        fields = ['id', 'name', 'url', 'master', 'member_count']
+        fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
 
 
 #

+ 6 - 4
netbox/dcim/api/serializers.py

@@ -352,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
         required=False
     )
     power_port = NestedPowerPortTemplateSerializer(
-        required=False
+        required=False,
+        allow_null=True
     )
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
@@ -524,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
     )
     speed = ChoiceField(
         choices=ConsolePortSpeedChoices,
-        allow_blank=True,
+        allow_null=True,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -548,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
     )
     speed = ChoiceField(
         choices=ConsolePortSpeedChoices,
-        allow_blank=True,
+        allow_null=True,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -571,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
         required=False
     )
     power_port = NestedPowerPortSerializer(
-        required=False
+        required=False,
+        allow_null=True
     )
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,

+ 2 - 0
netbox/dcim/choices.py

@@ -759,6 +759,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_80211AC = 'ieee802.11ac'
     TYPE_80211AD = 'ieee802.11ad'
     TYPE_80211AX = 'ieee802.11ax'
+    TYPE_802151 = 'ieee802.15.1'
 
     # Cellular
     TYPE_GSM = 'gsm'
@@ -871,6 +872,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_80211AC, 'IEEE 802.11ac'),
                 (TYPE_80211AD, 'IEEE 802.11ad'),
                 (TYPE_80211AX, 'IEEE 802.11ax'),
+                (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
             )
         ),
         (

+ 24 - 0
netbox/dcim/filtersets.py

@@ -876,6 +876,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='name',
         label='Device (name)',
     )
+    virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__virtual_chassis',
+        queryset=VirtualChassis.objects.all(),
+        label='Virtual Chassis (ID)'
+    )
+    virtual_chassis = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__virtual_chassis__name',
+        queryset=VirtualChassis.objects.all(),
+        to_field_name='name',
+        label='Virtual Chassis',
+    )
     tag = TagFilter()
 
     def search(self, queryset, name, value):
@@ -1416,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
 #
 
 class ConnectionFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     site_id = MultiValueNumberFilter(
         method='filter_connections',
         field_name='device__site_id'
@@ -1438,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet):
             return queryset
         return queryset.filter(**{f'{name}__in': value})
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(device__name__icontains=value) |
+            Q(cable__label__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
 
 class ConsoleConnectionFilterSet(ConnectionFilterSet):
 

+ 2 - 4
netbox/dcim/forms/connections.py

@@ -217,8 +217,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
         required=False
     )
 
-    class Meta:
-        model = Cable
+    class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
             'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
             'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
@@ -280,8 +279,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelF
         required=False
     )
 
-    class Meta:
-        model = Cable
+    class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
             'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
             'tenant', 'label', 'color', 'length', 'length_unit', 'tags',

+ 31 - 9
netbox/dcim/forms/filtersets.py

@@ -93,12 +93,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         label=_('Location'),
         fetch_trigger='open'
     )
+    virtual_chassis_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        required=False,
+        label=_('Virtual Chassis'),
+        fetch_trigger='open'
+    )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
         query_params={
             'site_id': '$site_id',
             'location_id': '$location_id',
+            'virtual_chassis_id': '$virtual_chassis_id'
         },
         label=_('Device'),
         fetch_trigger='open'
@@ -895,7 +902,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
@@ -915,7 +922,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
@@ -935,7 +942,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PowerPortTypeChoices,
@@ -950,7 +957,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PowerOutletTypeChoices,
@@ -966,7 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
         ['q', 'tag'],
         ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
         ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     kind = forms.MultipleChoiceField(
         choices=InterfaceKindChoices,
@@ -1031,7 +1038,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     model = FrontPort
     type = forms.MultipleChoiceField(
@@ -1050,7 +1057,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PortTypeChoices,
@@ -1068,7 +1075,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     tag = TagFilterField(model)
 
@@ -1078,7 +1085,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
-        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -1106,6 +1113,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
 #
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -1133,6 +1145,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -1160,6 +1177,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,

+ 10 - 9
netbox/dcim/svg.py

@@ -478,15 +478,16 @@ class CableTraceSVG:
                 parent_objects.append(parent_object)
 
             # Near end termination
-            termination = self._draw_box(
-                width=self.width * .8,
-                color=self._get_color(near_end),
-                url=near_end.get_absolute_url(),
-                labels=self._get_labels(near_end),
-                y_indent=PADDING,
-                radius=5
-            )
-            terminations.append(termination)
+            if near_end is not None:
+                termination = self._draw_box(
+                    width=self.width * .8,
+                    color=self._get_color(near_end),
+                    url=near_end.get_absolute_url(),
+                    labels=self._get_labels(near_end),
+                    y_indent=PADDING,
+                    radius=5
+                )
+                terminations.append(termination)
 
             # Connector (a Cable or WirelessLink)
             if connector is not None:

+ 25 - 1
netbox/dcim/tests/test_api.py

@@ -595,6 +595,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
             manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
         )
 
+        power_port_templates = (
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
+        )
+        PowerPortTemplate.objects.bulk_create(power_port_templates)
+
         power_outlet_templates = (
             PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
             PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
@@ -606,14 +612,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
             {
                 'device_type': devicetype.pk,
                 'name': 'Power Outlet Template 4',
+                'power_port': power_port_templates[0].pk,
             },
             {
                 'device_type': devicetype.pk,
                 'name': 'Power Outlet Template 5',
+                'power_port': power_port_templates[1].pk,
             },
             {
                 'device_type': devicetype.pk,
                 'name': 'Power Outlet Template 6',
+                'power_port': None,
             },
         ]
 
@@ -1044,14 +1053,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
             {
                 'device': device.pk,
                 'name': 'Console Port 4',
+                'speed': 9600,
             },
             {
                 'device': device.pk,
                 'name': 'Console Port 5',
+                'speed': 115200,
             },
             {
                 'device': device.pk,
                 'name': 'Console Port 6',
+                'speed': None,
             },
         ]
 
@@ -1083,14 +1095,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
             {
                 'device': device.pk,
                 'name': 'Console Server Port 4',
+                'speed': 9600,
             },
             {
                 'device': device.pk,
                 'name': 'Console Server Port 5',
+                'speed': 115200,
             },
             {
                 'device': device.pk,
                 'name': 'Console Server Port 6',
+                'speed': None,
             },
         ]
 
@@ -1150,6 +1165,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
         devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
 
+        power_ports = (
+            PowerPort(device=device, name='Power Port 1'),
+            PowerPort(device=device, name='Power Port 2'),
+        )
+        PowerPort.objects.bulk_create(power_ports)
+
         power_outlets = (
             PowerOutlet(device=device, name='Power Outlet 1'),
             PowerOutlet(device=device, name='Power Outlet 2'),
@@ -1161,14 +1182,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
             {
                 'device': device.pk,
                 'name': 'Power Outlet 4',
+                'power_port': power_ports[0].pk,
             },
             {
                 'device': device.pk,
                 'name': 'Power Outlet 5',
+                'power_port': power_ports[1].pk,
             },
             {
                 'device': device.pk,
                 'name': 'Power Outlet 6',
+                'power_port': None,
             },
         ]
 
@@ -1548,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase):
 
 class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
     model = VirtualChassis
-    brief_fields = ['id', 'master', 'member_count', 'name', 'url']
+    brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
 
     @classmethod
     def setUpTestData(cls):

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

@@ -2073,6 +2073,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Device.objects.bulk_create(devices)
 
+        # VirtualChassis assignment for filtering
+        virtual_chassis = VirtualChassis.objects.create(master=devices[0])
+        Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
+        Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
+
         interfaces = (
             Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
             Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
@@ -2197,6 +2202,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_virtual_chassis_id(self):
+        params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}

+ 60 - 0
netbox/extras/filtersets.py

@@ -28,6 +28,10 @@ __all__ = (
 
 
 class WebhookFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     content_types = ContentTypeFilter()
     http_method = django_filters.MultipleChoiceFilter(
         choices=WebhookHttpMethodChoices
@@ -40,30 +44,81 @@ class WebhookFilterSet(BaseFilterSet):
             'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
         ]
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(payload_url__icontains=value)
+        )
+
 
 class CustomFieldFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     content_types = ContentTypeFilter()
 
     class Meta:
         model = CustomField
         fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(label__icontains=value) |
+            Q(description__icontains=value)
+        )
+
 
 class CustomLinkFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
     class Meta:
         model = CustomLink
         fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(link_text__icontains=value) |
+            Q(link_url__icontains=value) |
+            Q(group_name__icontains=value)
+        )
+
 
 class ExportTemplateFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
     class Meta:
         model = ExportTemplate
         fields = ['id', 'content_type', 'name']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
 
 class ImageAttachmentFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     created = django_filters.DateTimeFilter()
     content_type = ContentTypeFilter()
 
@@ -71,6 +126,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
         model = ImageAttachment
         fields = ['id', 'content_type_id', 'object_id', 'name']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(name__icontains=value)
+
 
 class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(

+ 5 - 0
netbox/extras/scripts.py

@@ -3,6 +3,7 @@ import json
 import logging
 import os
 import pkgutil
+import sys
 import traceback
 from collections import OrderedDict
 
@@ -477,6 +478,10 @@ def get_scripts(use_names=False):
     # Iterate through all modules within the reports path. These are the user-created files in which reports are
     # defined.
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
+        # Remove cached module to ensure consistency with filesystem
+        if module_name in sys.modules:
+            del sys.modules[module_name]
+
         module = importer.find_module(module_name).load_module(module_name)
         if use_names and hasattr(module, 'name'):
             module_name = module.name

+ 2 - 2
netbox/extras/views.py

@@ -11,7 +11,7 @@ from rq import Worker
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.tables import paginate_table
-from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
+from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin
 from . import filtersets, forms, tables
 from .choices import JobResultStatusChoices
@@ -754,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
 
     def get(self, request, module, name):
         script = self._get_script(name, module)
-        form = script.as_form(initial=request.GET)
+        form = script.as_form(initial=normalize_querydict(request.GET))
 
         # Look for a pending JobResult (use the latest one by creation timestamp)
         script_content_type = ContentType.objects.get(app_label='extras', model='script')

+ 8 - 3
netbox/ipam/tables/ip.py

@@ -235,6 +235,11 @@ class PrefixTable(BaseTable):
     site = tables.Column(
         linkify=True
     )
+    vlan_group = tables.Column(
+        accessor='vlan__group',
+        linkify=True,
+        verbose_name='VLAN Group'
+    )
     vlan = tables.Column(
         linkify=True,
         verbose_name='VLAN'
@@ -259,8 +264,8 @@ class PrefixTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Prefix
         fields = (
-            'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
-            'is_pool', 'mark_utilized', 'description', 'tags',
+            'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
+            'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
         )
         default_columns = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@@ -347,7 +352,7 @@ class IPAddressTable(BaseTable):
         verbose_name='NAT (Inside)'
     )
     assigned = BooleanColumn(
-        accessor='assigned_object',
+        accessor='assigned_object_id',
         linkify=True,
         verbose_name='Assigned'
     )

+ 1 - 1
netbox/netbox/authentication.py

@@ -34,7 +34,7 @@ class ObjectPermissionMixin():
         object_permissions = ObjectPermission.objects.filter(
             self.get_permission_filter(user_obj),
             enabled=True
-        ).prefetch_related('object_types')
+        ).order_by('id').distinct('id').prefetch_related('object_types')
 
         # Create a dictionary mapping permissions to their constraints
         perms = defaultdict(list)

+ 38 - 28
netbox/netbox/views/generic.py

@@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
 
+    def get_table(self, request, permissions):
+        table = self.table(self.queryset, user=request.user)
+        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
+            table.columns.show('pk')
+
+        return table
+
     def export_yaml(self):
         """
         Export the queryset of objects as concatenated YAML documents.
@@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
         )
 
-    def get(self, request):
+    def export_template(self, template, request):
+        """
+        Render an ExportTemplate using the current queryset.
+
+        :param template: ExportTemplate instance
+        :param request: The current request
+        """
+        try:
+            return template.render_to_response(self.queryset)
+        except Exception as e:
+            messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
+            return redirect(request.path)
 
+    def get(self, request):
         model = self.queryset.model
         content_type = ContentType.objects.get_for_model(model)
 
@@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             perm_name = get_permission_for_model(model, action)
             permissions[action] = request.user.has_perm(perm_name)
 
-        # Export template/YAML rendering
-        if 'export' in request.GET and request.GET['export'] != 'table':
+        if 'export' in request.GET:
 
-            # An export template has been specified
-            if request.GET['export']:
-                et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
-                try:
-                    return et.render_to_response(self.queryset)
-                except Exception as e:
-                    messages.error(
-                        request,
-                        "There was an error rendering the selected export template ({}): {}".format(
-                            et.name, e
-                        )
-                    )
+            # Export the current table view
+            if request.GET['export'] == 'table':
+                table = self.get_table(request, permissions)
+                columns = [name for name, _ in table.selected_columns]
+                return self.export_table(table, columns)
 
-            # Check for YAML export support
+            # Render an ExportTemplate
+            elif request.GET['export']:
+                template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
+                return self.export_template(template, request)
+
+            # Check for YAML export support on the model
             elif hasattr(model, 'to_yaml'):
                 response = HttpResponse(self.export_yaml(), content_type='text/yaml')
                 filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
                 response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
                 return response
 
-        # Construct the objects table
-        table = self.table(self.queryset, user=request.user)
-        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
-            table.columns.show('pk')
-
-        # Handle table-based exports (current view or static CSV-based)
-        if request.GET.get('export') == 'table':
-            columns = [name for name, _ in table.selected_columns]
-            return self.export_table(table, columns)
-        elif 'export' in request.GET:
-            return self.export_table(table)
+            # Fall back to default table/YAML export
+            else:
+                table = self.get_table(request, permissions)
+                return self.export_table(table)
 
-        # Paginate the objects table
+        # Render the objects table
+        table = self.get_table(request, permissions)
         paginate_table(table, request)
 
         context = {

+ 88 - 102
netbox/templates/extras/report_list.html

@@ -3,108 +3,94 @@
 
 {% block title %}Reports{% endblock %}
 
-{% block content %}
-    <div class="row">
-        <div class="col col-md-9">
-            {% if reports %}
-                {% for module, module_reports in reports %}
-                    <div class="card">
-                        <h5 class="card-header"><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h5>
-                        <div class="card-body">
-                            <table class="table table-hover table-headings reports">
-                                <thead>
-                                    <tr>
-                                        <th>Name</th>
-                                        <th>Status</th>
-                                        <th>Description</th>
-                                        <th class="text-end">Last Run</th>
-                                        <th></th>
-                                    </tr>
-                                </thead>
-                                <tbody>
-                                    {% for report in module_reports %}
-                                        <tr>
-                                            <td>
-                                                <a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
-                                            </td>
-                                            <td>
-                                                {% include 'extras/inc/job_label.html' with result=report.result %}
-                                            </td>
-                                            <td>{{ report.description|render_markdown|placeholder }}</td>
-                                            <td class="text-end">
-                                                {% if report.result %}
-                                                    <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
-                                                {% else %}
-                                                    <span class="text-muted">Never</span>
-                                                {% endif %}
-                                            </td>
-                                        <td>
-                                            {% if perms.extras.run_report %}
-                                                <div class="float-end noprint">
-                                                    <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
-                                                        {% csrf_token %}
-                                                        <button type="submit" name="_run" class="btn btn-primary btn-sm">
-                                                            {% if report.result %}
-                                                                <i class="mdi mdi-replay"></i> Run Again
-                                                            {% else %}
-                                                                <i class="mdi mdi-play"></i> Run Report
-                                                            {% endif %}
-                                                        </button>
-                                                    </form>
-                                                </div>
-                                            {% endif %}
-                                        </td>
-                                        </tr>
-                                        {% for method, stats in report.result.data.items %}
-                                            <tr>
-                                                <td colspan="4" class="method">
-                                                    {{ method }}
-                                                </td>
-                                                <td class="text-end text-nowrap report-stats">
-                                                    <span class="badge bg-success">{{ stats.success }}</span>
-                                                    <span class="badge bg-info">{{ stats.info }}</span>
-                                                    <span class="badge bg-warning">{{ stats.warning }}</span>
-                                                    <span class="badge bg-danger">{{ stats.failure }}</span>
-                                                </td>
-                                            </tr>
-                                        {% endfor %}
-                                    {% endfor %}
-                                </tbody>
-                            </table>
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a class="nav-link active" role="tab">Reports</a>
+    </li>
+  </ul>
+{% endblock tabs %}
+
+{% block content-wrapper %}
+  <div class="tab-content">
+    {% if reports %}
+      {% for module, module_reports in reports %}
+        <div class="card">
+          <h5 class="card-header">
+            <a name="module.{{ module }}"></a>
+            <i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
+          </h5>
+          <div class="card-body">
+            <table class="table table-hover table-headings reports">
+              <thead>
+                <tr>
+                  <th width="250">Name</th>
+                  <th width="110">Status</th>
+                  <th>Description</th>
+                  <th width="150" class="text-end">Last Run</th>
+                  <th width="120"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {% for report in module_reports %}
+                  <tr>
+                    <td>
+                      <a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
+                    </td>
+                    <td>
+                      {% include 'extras/inc/job_label.html' with result=report.result %}
+                    </td>
+                    <td>{{ report.description|render_markdown|placeholder }}</td>
+                    <td class="text-end">
+                      {% if report.result %}
+                        <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
+                      {% else %}
+                        <span class="text-muted">Never</span>
+                      {% endif %}
+                    </td>
+                    <td>
+                      {% if perms.extras.run_report %}
+                        <div class="float-end noprint">
+                          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
+                            {% csrf_token %}
+                            <button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
+                              {% if report.result %}
+                                <i class="mdi mdi-replay"></i> Run Again
+                              {% else %}
+                                <i class="mdi mdi-play"></i> Run Report
+                              {% endif %}
+                            </button>
+                          </form>
                         </div>
-                    </div>
+                      {% endif %}
+                    </td>
+                  </tr>
+                  {% for method, stats in report.result.data.items %}
+                    <tr>
+                      <td colspan="4" class="method">
+                        <span class="ps-3">{{ method }}</span>
+                      </td>
+                      <td class="text-end text-nowrap report-stats">
+                        <span class="badge bg-success">{{ stats.success }}</span>
+                        <span class="badge bg-info">{{ stats.info }}</span>
+                        <span class="badge bg-warning">{{ stats.warning }}</span>
+                        <span class="badge bg-danger">{{ stats.failure }}</span>
+                      </td>
+                    </tr>
+                  {% endfor %}
                 {% endfor %}
-            {% else %}
-                <div class="alert alert-info" role="alert">
-                    <h4 class="alert-heading">No Reports Found</h4>
-                    Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
-                    <hr/>
-                    <small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
-                </div>
-            {% endif %}
-        </div>
-        <div class="col col-md-3">
-            {% if reports %}
-                <div class="card">
-                    <div class="card-body">
-                        {% for module, module_reports in reports %}
-                        <h5>{{ module|bettertitle }}</h5>
-                        <div class="small mb-2">
-                            <ul class="list-group list-group-flush">
-                                {% for report in module_reports %}
-                                <a href="#{{ report.module }}.{{ report.class_name }}" class="list-group-item">
-                                    <i class="mdi mdi-file-chart-outline"></i> {{ report.name }}
-                                    <div class="float-end">
-                                        {% include 'extras/inc/job_label.html' with result=report.result %}
-                                    </div>
-                                </a>
-                                {% endfor %}
-                            </ul>
-                        </div>
-                    {% endfor %}
-                    </div>
-                </div>
-            {% endif %}
+              </tbody>
+            </table>
+          </div>
         </div>
-    </div>
-{% endblock %}
+      {% endfor %}
+    {% else %}
+      <div class="alert alert-info" role="alert">
+        <h4 class="alert-heading">No Reports Found</h4>
+        Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
+        <hr/>
+        <small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
+      </div>
+    {% endif %}
+  </div>
+{% endblock content-wrapper %}

+ 61 - 69
netbox/templates/extras/script_list.html

@@ -3,74 +3,66 @@
 
 {% block title %}Scripts{% endblock %}
 
-{% block content %}
-    <div class="row">
-        <div class="col col-md-9">
-            {% if scripts %}
-                {% for module, module_scripts in scripts.items %}
-                    <h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
-                    <table class="table table-hover table-headings reports">
-                        <thead>
-                            <tr>
-                                <th>Name</th>
-                                <th>Status</th>
-                                <th>Description</th>
-                                <th class="text-end">Last Run</th>
-                            </tr>
-                        </thead>
-                        <tbody>
-                            {% for class_name, script in module_scripts.items %}
-                                <tr>
-                                    <td>
-                                        <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
-                                    </td>
-                                    <td>
-                                        {% include 'extras/inc/job_label.html' with result=script.result %}
-                                    </td>
-                                    <td>{{ script.Meta.description|render_markdown }}</td>
-                                    {% if script.result %}
-                                        <td class="text-end">
-                                            <a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
-                                        </td>
-                                    {% else %}
-                                        <td class="text-end text-muted">Never</td>
-                                    {% endif %}
-                                </tr>
-                            {% endfor %}
-                        </tbody>
-                    </table>
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <a class="nav-link active" role="tab">Scripts</a>
+    </li>
+  </ul>
+{% endblock tabs %}
+
+{% block content-wrapper %}
+  <div class="tab-content">
+    {% if scripts %}
+      {% for module, module_scripts in scripts.items %}
+        <div class="card">
+          <h5 class="card-header">
+            <a name="module.{{ module }}"></a>
+            <i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
+          </h5>
+          <div class="card-body">
+            <table class="table table-hover table-headings reports">
+              <thead>
+                <tr>
+                  <th width="250">Name</th>
+                  <th width="110">Status</th>
+                  <th>Description</th>
+                  <th class="text-end">Last Run</th>
+                </tr>
+              </thead>
+              <tbody>
+                {% for class_name, script in module_scripts.items %}
+                  <tr>
+                    <td>
+                      <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
+                    </td>
+                    <td>
+                      {% include 'extras/inc/job_label.html' with result=script.result %}
+                    </td>
+                    <td>
+                      {{ script.Meta.description|render_markdown|placeholder }}
+                    </td>
+                    {% if script.result %}
+                      <td class="text-end">
+                        <a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
+                      </td>
+                    {% else %}
+                      <td class="text-end text-muted">Never</td>
+                    {% endif %}
+                  </tr>
                 {% endfor %}
-            {% else %}
-                <div class="alert alert-info">
-                    <h4 class="alert-heading">No Scripts Found</h4>
-                    Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
-                    <hr/>
-                    This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
-                </div>
-            {% endif %}
-        </div>
-        <div class="col col-md-3">
-            {% if scripts %}
-                <div class="card">
-                    <div class="card-body">
-                        {% for module, module_scripts in scripts.items %}
-                        <h5>{{ module|bettertitle }}</h5>
-                        <div class="small mb-2">
-                            <ul class="list-group list-group-flush">
-                                {% for class_name, script in module_scripts.items %}
-                                    <a href="#script.{{ class_name }}" class="list-group-item">
-                                        <i class="mdi mdi-file-chart-outline"></i> {{ script.name }}
-                                        <div class="float-end">
-                                            {% include 'extras/inc/job_label.html' with result=script.result %}
-                                        </div>
-                                    </a>
-                                {% endfor %}
-                            </ul>
-                        </div>
-                        {% endfor %}
-                    </div>
-                </div>
-            {% endif %}
+              </tbody>
+            </table>
+          </div>
         </div>
-    </div>
-{% endblock %}
+      {% endfor %}
+    {% else %}
+      <div class="alert alert-info">
+        <h4 class="alert-heading">No Scripts Found</h4>
+        Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
+        <hr/>
+        This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
+      </div>
+    {% endif %}
+  </div>
+{% endblock content-wrapper %}

+ 2 - 1
netbox/templates/ipam/vlangroup.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -69,7 +70,7 @@
         VLANs
       </h5>
       <div class="card-body">
-        {% include 'inc/table.html' with table=vlans_table %}
+        {% render_table vlans_table 'inc/table.html' %}
       </div>
       {% if perms.ipam.add_vlan %}
         <div class="card-footer text-end noprint">

+ 20 - 0
netbox/users/filtersets.py

@@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet):
         model = Token
         fields = ['id', 'key', 'write_enabled']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user__username__icontains=value) |
+            Q(description__icontains=value)
+        )
+
 
 class ObjectPermissionFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     user_id = django_filters.ModelMultipleChoiceFilter(
         field_name='users',
         queryset=User.objects.all(),
@@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet):
     class Meta:
         model = ObjectPermission
         fields = ['id', 'name', 'enabled', 'object_types']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )

+ 16 - 0
netbox/utilities/markdown.py

@@ -0,0 +1,16 @@
+import markdown
+from markdown.inlinepatterns import SimpleTagPattern
+
+STRIKE_RE = r'(~{2})(.+?)(~{2})'
+
+
+class StrikethroughExtension(markdown.Extension):
+    """
+    A python-markdown extension which support strikethrough formatting (e.g. "~~text~~").
+    """
+    def extendMarkdown(self, md):
+        md.inlinePatterns.register(
+            markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del'),
+            'strikethrough',
+            200
+        )

+ 2 - 1
netbox/utilities/templatetags/helpers.py

@@ -17,6 +17,7 @@ from markdown import markdown
 
 from netbox.config import get_config
 from utilities.forms import get_selected_values, TableConfigForm
+from utilities.markdown import StrikethroughExtension
 from utilities.utils import foreground_color
 
 register = template.Library()
@@ -56,7 +57,7 @@ def render_markdown(value):
     value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
 
     # Render Markdown
-    html = markdown(value, extensions=['fenced_code', 'tables'])
+    html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
 
     return mark_safe(html)
 

+ 6 - 0
netbox/virtualization/filtersets.py

@@ -146,6 +146,12 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf
         queryset=Cluster.objects.all(),
         label='Cluster (ID)',
     )
+    cluster = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster__name',
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        label='Cluster',
+    )
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='cluster__site__region',

+ 2 - 3
netbox/virtualization/tests/test_filtersets.py

@@ -324,9 +324,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         clusters = Cluster.objects.all()[:2]
         params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        # TODO: 'cluster' should match on name
-        # params = {'cluster': [clusters[0].name, clusters[1].name]}
-        # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cluster': [clusters[0].name, clusters[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_region(self):
         regions = Region.objects.all()[:2]