2
0
Эх сурвалжийг харах

Merge pull request #7922 from netbox-community/develop

Release v3.0.11
Jeremy Stretch 4 жил өмнө
parent
commit
869808b3f9
38 өөрчлөгдсөн 512 нэмэгдсэн , 281 устгасан
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      docs/administration/housekeeping.md
  4. 1 1
      docs/installation/3-netbox.md
  5. 1 1
      docs/installation/upgrading.md
  6. 28 1
      docs/release-notes/version-3.0.md
  7. 1 1
      netbox/dcim/api/nested_serializers.py
  8. 6 4
      netbox/dcim/api/serializers.py
  9. 5 3
      netbox/dcim/choices.py
  10. 24 0
      netbox/dcim/filtersets.py
  11. 2 4
      netbox/dcim/forms/connections.py
  12. 31 9
      netbox/dcim/forms/filtersets.py
  13. 10 9
      netbox/dcim/svg.py
  14. 25 1
      netbox/dcim/tests/test_api.py
  15. 9 0
      netbox/dcim/tests/test_filtersets.py
  16. 10 10
      netbox/extras/context_managers.py
  17. 60 0
      netbox/extras/filtersets.py
  18. 5 0
      netbox/extras/scripts.py
  19. 17 9
      netbox/extras/signals.py
  20. 2 2
      netbox/extras/views.py
  21. 8 3
      netbox/ipam/tables/ip.py
  22. 3 0
      netbox/netbox/__init__.py
  23. 1 1
      netbox/netbox/authentication.py
  24. 3 3
      netbox/netbox/middleware.py
  25. 9 0
      netbox/netbox/request_context.py
  26. 1 1
      netbox/netbox/settings.py
  27. 38 28
      netbox/netbox/views/generic.py
  28. 88 102
      netbox/templates/extras/report_list.html
  29. 61 69
      netbox/templates/extras/script_list.html
  30. 10 2
      netbox/templates/extras/webhook.html
  31. 2 1
      netbox/templates/ipam/vlangroup.html
  32. 20 0
      netbox/users/filtersets.py
  33. 16 0
      netbox/utilities/markdown.py
  34. 2 1
      netbox/utilities/templatetags/helpers.py
  35. 0 7
      netbox/utilities/utils.py
  36. 6 0
      netbox/virtualization/filtersets.py
  37. 2 3
      netbox/virtualization/tests/test_filtersets.py
  38. 2 2
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.10
+      placeholder: v3.0.11
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.10
+      placeholder: v3.0.11
     validations:
       required: true
   - type: dropdown

+ 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.

+ 28 - 1
docs/release-notes/version-3.0.md

@@ -1,5 +1,32 @@
 # NetBox v3.0
 
+## v3.0.11 (2021-11-24)
+
+### 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
+* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
+* [#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)
 
 ### Enhancements
@@ -422,7 +449,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
 * [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
 * [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
 
-### Bug Fixes (from v3.2-beta2)
+### Bug Fixes (from v3.0-beta2)
 
 * [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
 * [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations

+ 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

@@ -356,7 +356,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
         required=False
     )
     power_port = NestedPowerPortTemplateSerializer(
-        required=False
+        required=False,
+        allow_null=True
     )
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
@@ -538,7 +539,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
     )
     speed = ChoiceField(
         choices=ConsolePortSpeedChoices,
-        allow_blank=True,
+        allow_null=True,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -562,7 +563,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
     )
     speed = ChoiceField(
         choices=ConsolePortSpeedChoices,
-        allow_blank=True,
+        allow_null=True,
         required=False
     )
     cable = NestedCableSerializer(read_only=True)
@@ -585,7 +586,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
         required=False
     )
     power_port = NestedPowerPortSerializer(
-        required=False
+        required=False,
+        allow_null=True
     )
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,

+ 5 - 3
netbox/dcim/choices.py

@@ -428,7 +428,7 @@ class PowerPortTypeChoices(ChoiceSet):
         )),
         ('International/ITA', (
             (TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
-            (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+            (TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
             (TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
             (TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
             (TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -640,8 +640,8 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_CS8464C, 'CS8464C'),
         )),
         ('ITA/International', (
-            (TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
-            (TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
+            (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+            (TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
             (TYPE_ITA_G, 'ITA Type G (BS 1363)'),
             (TYPE_ITA_H, 'ITA Type H'),
             (TYPE_ITA_I, 'ITA Type I'),
@@ -739,6 +739,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'
@@ -850,6 +851,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

@@ -861,6 +861,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):
@@ -1394,6 +1405,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'
@@ -1416,6 +1431,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

@@ -215,8 +215,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
         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', 'label', 'color', 'length', 'length_unit', 'tags',
@@ -277,8 +276,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
         required=False
     )
 
-    class Meta:
-        model = Cable
+    class Meta(ConnectCableToDeviceForm.Meta):
         fields = [
             'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
             'color', 'length', 'length_unit', 'tags',

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

@@ -92,12 +92,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'
@@ -888,7 +895,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,
@@ -908,7 +915,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,
@@ -928,7 +935,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,
@@ -943,7 +950,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,
@@ -958,7 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
     field_groups = [
         ['q', 'tag'],
         ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
-        ['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,
@@ -993,7 +1000,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(
@@ -1012,7 +1019,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,
@@ -1030,7 +1037,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)
 
@@ -1040,7 +1047,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(),
@@ -1068,6 +1075,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,
@@ -1095,6 +1107,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,
@@ -1122,6 +1139,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

@@ -442,15 +442,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 (either a Cable or attachment to a ProviderNetwork)
             if connector is not None:

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

@@ -584,6 +584,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'),
@@ -595,14 +601,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,
             },
         ]
 
@@ -1033,14 +1042,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,
             },
         ]
 
@@ -1072,14 +1084,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,
             },
         ]
 
@@ -1139,6 +1154,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'),
@@ -1150,14 +1171,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,
             },
         ]
 
@@ -1524,7 +1548,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

@@ -2048,6 +2048,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'),
@@ -2157,6 +2162,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]}

+ 10 - 10
netbox/extras/context_managers.py

@@ -2,8 +2,9 @@ from contextlib import contextmanager
 
 from django.db.models.signals import m2m_changed, pre_delete, post_save
 
-from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
-from utilities.utils import curry
+from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
+from netbox import thread_locals
+from netbox.request_context import set_request
 from .webhooks import flush_webhooks
 
 
@@ -15,12 +16,8 @@ def change_logging(request):
 
     :param request: WSGIRequest object with a unique `id` set
     """
-    webhook_queue = []
-
-    # Curry signals receivers to pass the current request
-    handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
-    handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
-    clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
+    set_request(request)
+    thread_locals.webhook_queue = []
 
     # Connect our receivers to the post_save and post_delete signals.
     post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@@ -38,5 +35,8 @@ def change_logging(request):
     clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
 
     # Flush queued webhooks to RQ
-    flush_webhooks(webhook_queue)
-    del webhook_queue
+    flush_webhooks(thread_locals.webhook_queue)
+    del thread_locals.webhook_queue
+
+    # Clear the request from thread-local storage
+    set_request(None)

+ 60 - 0
netbox/extras/filtersets.py

@@ -35,6 +35,10 @@ EXACT_FILTER_TYPES = (
 
 
 class WebhookFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     content_types = ContentTypeFilter()
     http_method = django_filters.MultipleChoiceFilter(
         choices=WebhookHttpMethodChoices
@@ -47,30 +51,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()
 
@@ -78,6 +133,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

+ 17 - 9
netbox/extras/signals.py

@@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
+from netbox import thread_locals
+from netbox.request_context import get_request
 from netbox.signals import post_clean
 from .choices import ObjectChangeActionChoices
 from .models import CustomField, ObjectChange
@@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 clear_webhooks = Signal()
 
 
-def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
+def handle_changed_object(sender, instance, **kwargs):
     """
     Fires when an object is created or updated.
     """
+    if not hasattr(instance, 'to_objectchange'):
+        return
+
+    request = get_request()
+    m2m_changed = False
+
     def is_same_object(instance, webhook_data):
         return (
             ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
@@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
             request.id == webhook_data['request_id']
         )
 
-    if not hasattr(instance, 'to_objectchange'):
-        return
-
-    m2m_changed = False
-
     # Determine the type of change being made
     if kwargs.get('created'):
         action = ObjectChangeActionChoices.ACTION_CREATE
@@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
             objectchange.save()
 
     # If this is an M2M change, update the previously queued webhook (from post_save)
+    webhook_queue = thread_locals.webhook_queue
     if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
         instance.refresh_from_db()  # Ensure that we're working with fresh M2M assignments
         webhook_queue[-1]['data'] = serialize_for_webhook(instance)
@@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
         model_updates.labels(instance._meta.model_name).inc()
 
 
-def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
+def handle_deleted_object(sender, instance, **kwargs):
     """
     Fires when an object is deleted.
     """
     if not hasattr(instance, 'to_objectchange'):
         return
 
+    request = get_request()
+
     # Record an ObjectChange if applicable
     if hasattr(instance, 'to_objectchange'):
         objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
         objectchange.save()
 
     # Enqueue webhooks
+    webhook_queue = thread_locals.webhook_queue
     enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
 
     # Increment metric counters
     model_deletes.labels(instance._meta.model_name).inc()
 
 
-def _clear_webhook_queue(webhook_queue, sender, **kwargs):
+def clear_webhook_queue(sender, **kwargs):
     """
     Delete any queued webhooks (e.g. because of an aborted bulk transaction)
     """
     logger = logging.getLogger('webhooks')
-    logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
+    webhook_queue = thread_locals.webhook_queue
 
+    logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
     webhook_queue.clear()
 
 

+ 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

@@ -206,6 +206,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'
@@ -230,8 +235,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',
@@ -318,7 +323,7 @@ class IPAddressTable(BaseTable):
         verbose_name='NAT (Inside)'
     )
     assigned = BooleanColumn(
-        accessor='assigned_object',
+        accessor='assigned_object_id',
         linkify=True,
         verbose_name='Assigned'
     )

+ 3 - 0
netbox/netbox/__init__.py

@@ -0,0 +1,3 @@
+import threading
+
+thread_locals = threading.local()

+ 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)

+ 3 - 3
netbox/netbox/middleware.py

@@ -1,10 +1,10 @@
+import logging
 import uuid
 from urllib import parse
-import logging
 
 from django.conf import settings
-from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.contrib import auth
+from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
 from django.db import ProgrammingError
 from django.http import Http404, HttpResponseRedirect
@@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
         return groups
 
 
-class ObjectChangeMiddleware(object):
+class ObjectChangeMiddleware:
     """
     This middleware performs three functions in response to an object being created, updated, or deleted:
 

+ 9 - 0
netbox/netbox/request_context.py

@@ -0,0 +1,9 @@
+from netbox import thread_locals
+
+
+def set_request(request):
+    thread_locals.request = request
+
+
+def get_request():
+    return getattr(thread_locals, 'request', None)

+ 1 - 1
netbox/netbox/settings.py

@@ -17,7 +17,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '3.0.10'
+VERSION = '3.0.11'
 
 # Hostname
 HOSTNAME = platform.node()

+ 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 %}

+ 10 - 2
netbox/templates/extras/webhook.html

@@ -137,7 +137,11 @@
         Additional Headers
       </h5>
       <div class="card-body">
-        <pre>{{ object.additional_headers }}</pre>
+        {% if object.additional_headers %}
+          <pre>{{ object.additional_headers }}</pre>
+        {% else %}
+          <span class="text-muted">None</span>
+        {% endif %}
       </div>
     </div>
     <div class="card">
@@ -145,7 +149,11 @@
         Body Template
       </h5>
       <div class="card-body">
-        <pre>{{ object.body_template }}</pre>
+        {% if object.body_template %}
+          <pre>{{ object.body_template }}</pre>
+        {% else %}
+          <span class="text-muted">None</span>
+        {% endif %}
       </div>
     </div>
     {% plugin_right_page object %}

+ 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 }}
@@ -68,7 +69,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

@@ -15,6 +15,7 @@ from django.utils.safestring import mark_safe
 from markdown import markdown
 
 from utilities.forms import get_selected_values, TableConfigForm
+from utilities.markdown import StrikethroughExtension
 from utilities.utils import foreground_color
 
 register = template.Library()
@@ -54,7 +55,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)
 

+ 0 - 7
netbox/utilities/utils.py

@@ -327,13 +327,6 @@ def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict:
     return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()}
 
 
-# Taken from django.utils.functional (<3.0)
-def curry(_curried_func, *args, **kwargs):
-    def _curried(*moreargs, **morekwargs):
-        return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
-    return _curried
-
-
 def array_to_string(array):
     """
     Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.

+ 6 - 0
netbox/virtualization/filtersets.py

@@ -144,6 +144,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]

+ 2 - 2
requirements.txt

@@ -7,7 +7,7 @@ django-mptt==0.13.4
 django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-redis==5.0.0
-django-rq==2.4.1
+django-rq==2.5.1
 django-tables2==2.4.1
 django-taggit==1.5.1
 django-timezone-field==4.2.1
@@ -16,7 +16,7 @@ drf-yasg[validation]==1.20.0
 graphene_django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.0.3
-Markdown==3.3.4
+Markdown==3.3.6
 markdown-include==0.6.0
 mkdocs-material==7.3.6
 netaddr==0.8.0