Przeglądaj źródła

Merge pull request #9801 from netbox-community/develop

Release v3.2.7
Jeremy Stretch 3 lat temu
rodzic
commit
f8cbd322ba
43 zmienionych plików z 423 dodań i 446 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      docs/administration/authentication/overview.md
  4. 1 1
      docs/models/extras/webhook.md
  5. 21 0
      docs/release-notes/version-3.2.md
  6. 9 8
      netbox/dcim/api/serializers.py
  7. 3 2
      netbox/dcim/api/views.py
  8. 6 3
      netbox/dcim/filtersets.py
  9. 2 2
      netbox/dcim/forms/filtersets.py
  10. 10 2
      netbox/dcim/svg.py
  11. 11 9
      netbox/dcim/tests/test_filtersets.py
  12. 1 3
      netbox/dcim/views.py
  13. 3 2
      netbox/extras/api/customfields.py
  14. 4 3
      netbox/extras/api/serializers.py
  15. 4 3
      netbox/ipam/api/serializers.py
  16. 1 3
      netbox/ipam/views.py
  17. 2 1
      netbox/netbox/api/viewsets/__init__.py
  18. 3 254
      netbox/netbox/constants.py
  19. 1 1
      netbox/netbox/filtersets.py
  20. 1 1
      netbox/netbox/forms/__init__.py
  21. 261 0
      netbox/netbox/search.py
  22. 9 8
      netbox/netbox/settings.py
  23. 2 1
      netbox/netbox/views/__init__.py
  24. 0 0
      netbox/project-static/dist/netbox.js
  25. 0 0
      netbox/project-static/dist/netbox.js.map
  26. 3 1
      netbox/project-static/src/buttons/reslug.ts
  27. 1 39
      netbox/project-static/src/forms/elements.ts
  28. 0 1
      netbox/project-static/src/select/api/apiSelect.ts
  29. 0 14
      netbox/templates/circuits/circuittermination_edit.html
  30. 0 11
      netbox/templates/dcim/interface_edit.html
  31. 1 1
      netbox/templates/generic/bulk_delete.html
  32. 1 1
      netbox/templates/generic/bulk_edit.html
  33. 6 6
      netbox/templates/generic/bulk_import.html
  34. 1 1
      netbox/templates/generic/bulk_remove.html
  35. 1 1
      netbox/templates/generic/bulk_rename.html
  36. 18 27
      netbox/templates/generic/confirmation_form.html
  37. 4 4
      netbox/templates/generic/object_edit.html
  38. 15 15
      netbox/templates/generic/object_import.html
  39. 0 11
      netbox/templates/virtualization/vminterface_edit.html
  40. 2 1
      netbox/tenancy/api/serializers.py
  41. 8 2
      netbox/users/views.py
  42. 2 0
      netbox/utilities/templatetags/builtins/filters.py
  43. 2 0
      netbox/virtualization/views.py

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

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

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

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

+ 1 - 1
docs/administration/authentication/overview.md

@@ -34,4 +34,4 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
 
 
 NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
 NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
 
 
-Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
+Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)

+ 1 - 1
docs/models/extras/webhook.md

@@ -43,7 +43,7 @@ The following data is available as context for Jinja2 templates:
 * `username` - The name of the user account associated with the change.
 * `username` - The name of the user account associated with the change.
 * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
 * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
-* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
+* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
 
 
 ### Default Request Body
 ### Default Request Body
 
 

+ 21 - 0
docs/release-notes/version-3.2.md

@@ -1,5 +1,26 @@
 # NetBox v3.2
 # NetBox v3.2
 
 
+## v3.2.7 (2022-07-20)
+
+### Enhancements
+
+* [#9705](https://github.com/netbox-community/netbox/issues/9705) - Support filter expressions for the `serial` field on racks, devices, and inventory items
+* [#9741](https://github.com/netbox-community/netbox/issues/9741) - Check for UserConfig instance during user login
+* [#9745](https://github.com/netbox-community/netbox/issues/9745) - Add wireless LANs and links to global search
+
+### Bug Fixes
+
+* [#9437](https://github.com/netbox-community/netbox/issues/9437) - Standardize form submission buttons and behavior when using enter key
+* [#9499](https://github.com/netbox-community/netbox/issues/9499) - Fix filtered bulk deletion of VM Interfaces
+* [#9634](https://github.com/netbox-community/netbox/issues/9634) - Fix image URLs in rack elevations when using external storage
+* [#9715](https://github.com/netbox-community/netbox/issues/9715) - Fix `SOCIAL_AUTH_PIPELINE` config parameter not taking effect
+* [#9754](https://github.com/netbox-community/netbox/issues/9754) - Fix regression introduced by #9632
+* [#9746](https://github.com/netbox-community/netbox/issues/9746) - Permit filtering interfaces by arbitrary speed value in UI
+* [#9749](https://github.com/netbox-community/netbox/issues/9749) - Retain original slug values when modifying object names
+* [#9775](https://github.com/netbox-community/netbox/issues/9775) - Fix exception when viewing a report with no description
+
+---
+
 ## v3.2.6 (2022-07-11)
 ## v3.2.6 (2022-07-11)
 
 
 ### Enhancements
 ### Enhancements

+ 9 - 8
netbox/dcim/api/serializers.py

@@ -15,6 +15,7 @@ from netbox.api.serializers import (
     NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
     NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 )
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -41,7 +42,7 @@ class LinkTerminationSerializer(serializers.ModelSerializer):
         Return the appropriate serializer for the link termination model.
         Return the appropriate serializer for the link termination model.
         """
         """
         if obj._link_peer is not None:
         if obj._link_peer is not None:
-            serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
+            serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             return serializer(obj._link_peer, context=context).data
             return serializer(obj._link_peer, context=context).data
         return None
         return None
@@ -67,7 +68,7 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer):
         Return the appropriate serializer for the type of connected object.
         Return the appropriate serializer for the type of connected object.
         """
         """
         if obj._path is not None and obj._path.destination is not None:
         if obj._path is not None and obj._path.destination is not None:
-            serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
+            serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             return serializer(obj._path.destination, context=context).data
             return serializer(obj._path.destination, context=context).data
         return None
         return None
@@ -543,7 +544,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
     def get_component(self, obj):
     def get_component(self, obj):
         if obj.component is None:
         if obj.component is None:
             return None
             return None
-        serializer = get_serializer_for_model(obj.component, prefix='Nested')
+        serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.component, context=context).data
         return serializer(obj.component, context=context).data
 
 
@@ -935,7 +936,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
     def get_component(self, obj):
     def get_component(self, obj):
         if obj.component is None:
         if obj.component is None:
             return None
             return None
-        serializer = get_serializer_for_model(obj.component, prefix='Nested')
+        serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.component, context=context).data
         return serializer(obj.component, context=context).data
 
 
@@ -991,7 +992,7 @@ class CableSerializer(NetBoxModelSerializer):
         termination = getattr(obj, 'termination_{}'.format(side.lower()))
         termination = getattr(obj, 'termination_{}'.format(side.lower()))
         if termination is None:
         if termination is None:
             return None
             return None
-        serializer = get_serializer_for_model(termination, prefix='Nested')
+        serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         data = serializer(termination, context=context).data
         data = serializer(termination, context=context).data
 
 
@@ -1037,7 +1038,7 @@ class CablePathSerializer(serializers.ModelSerializer):
         """
         """
         Return the appropriate serializer for the origin.
         Return the appropriate serializer for the origin.
         """
         """
-        serializer = get_serializer_for_model(obj.origin, prefix='Nested')
+        serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.origin, context=context).data
         return serializer(obj.origin, context=context).data
 
 
@@ -1047,7 +1048,7 @@ class CablePathSerializer(serializers.ModelSerializer):
         Return the appropriate serializer for the destination, if any.
         Return the appropriate serializer for the destination, if any.
         """
         """
         if obj.destination_id is not None:
         if obj.destination_id is not None:
-            serializer = get_serializer_for_model(obj.destination, prefix='Nested')
+            serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             return serializer(obj.destination, context=context).data
             return serializer(obj.destination, context=context).data
         return None
         return None
@@ -1056,7 +1057,7 @@ class CablePathSerializer(serializers.ModelSerializer):
     def get_path(self, obj):
     def get_path(self, obj):
         ret = []
         ret = []
         for node in obj.get_path():
         for node in obj.get_path():
-            serializer = get_serializer_for_model(node, prefix='Nested')
+            serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             ret.append(serializer(node, context=context).data)
             ret.append(serializer(node, context=context).data)
         return ret
         return ret

+ 3 - 2
netbox/dcim/api/views.py

@@ -22,6 +22,7 @@ from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.config import get_config
 from netbox.config import get_config
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
@@ -69,14 +70,14 @@ class PathEndpointMixin(object):
                 break
                 break
 
 
             # Serialize each object
             # Serialize each object
-            serializer_a = get_serializer_for_model(near_end, prefix='Nested')
+            serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
             x = serializer_a(near_end, context={'request': request}).data
             x = serializer_a(near_end, context={'request': request}).data
             if cable is not None:
             if cable is not None:
                 y = serializers.TracedCableSerializer(cable, context={'request': request}).data
                 y = serializers.TracedCableSerializer(cable, context={'request': request}).data
             else:
             else:
                 y = None
                 y = None
             if far_end is not None:
             if far_end is not None:
-                serializer_b = get_serializer_for_model(far_end, prefix='Nested')
+                serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
                 z = serializer_b(far_end, context={'request': request}).data
                 z = serializer_b(far_end, context={'request': request}).data
             else:
             else:
                 z = None
                 z = None

+ 6 - 3
netbox/dcim/filtersets.py

@@ -307,7 +307,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    serial = django_filters.CharFilter(
+    serial = MultiValueCharFilter(
         lookup_expr='iexact'
         lookup_expr='iexact'
     )
     )
 
 
@@ -1002,10 +1002,13 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
     )
     )
+    serial = MultiValueCharFilter(
+        lookup_expr='iexact'
+    )
 
 
     class Meta:
     class Meta:
         model = Module
         model = Module
-        fields = ['id', 'serial', 'asset_tag']
+        fields = ['id', 'asset_tag']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1400,7 +1403,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
     )
     )
     component_type = ContentTypeFilter()
     component_type = ContentTypeFilter()
     component_id = MultiValueNumberFilter()
     component_id = MultiValueNumberFilter()
-    serial = django_filters.CharFilter(
+    serial = MultiValueCharFilter(
         lookup_expr='iexact'
         lookup_expr='iexact'
     )
     )
 
 

+ 2 - 2
netbox/dcim/forms/filtersets.py

@@ -982,8 +982,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
     )
     )
     speed = forms.IntegerField(
     speed = forms.IntegerField(
         required=False,
         required=False,
-        label='Select Speed',
-        widget=SelectSpeedWidget(attrs={'readonly': None})
+        label='Speed',
+        widget=SelectSpeedWidget()
     )
     )
     duplex = MultipleChoiceField(
     duplex = MultipleChoiceField(
         choices=InterfaceDuplexChoices,
         choices=InterfaceDuplexChoices,

+ 10 - 2
netbox/dcim/svg.py

@@ -113,8 +113,12 @@ class RackElevationSVG:
 
 
         # Embed front device type image if one exists
         # Embed front device type image if one exists
         if self.include_images and device.device_type.front_image:
         if self.include_images and device.device_type.front_image:
+            url = device.device_type.front_image.url
+            # Convert any relative URLs to absolute
+            if url.startswith('/'):
+                url = '{}{}'.format(self.base_url, url)
             image = drawing.image(
             image = drawing.image(
-                href='{}{}'.format(self.base_url, device.device_type.front_image.url),
+                href=url,
                 insert=start,
                 insert=start,
                 size=end,
                 size=end,
                 class_='device-image'
                 class_='device-image'
@@ -139,8 +143,12 @@ class RackElevationSVG:
 
 
         # Embed rear device type image if one exists
         # Embed rear device type image if one exists
         if self.include_images and device.device_type.rear_image:
         if self.include_images and device.device_type.rear_image:
+            url = device.device_type.rear_image.url
+            # Convert any relative URLs to absolute
+            if url.startswith('/'):
+                url = '{}{}'.format(self.base_url, url)
             image = drawing.image(
             image = drawing.image(
-                href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
+                href=url,
                 insert=start,
                 insert=start,
                 size=end,
                 size=end,
                 class_='device-image'
                 class_='device-image'

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

@@ -494,10 +494,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_serial(self):
     def test_serial(self):
-        params = {'serial': 'ABC'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'serial': 'abc'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'serial': ['ABC', 'DEF']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'serial': ['abc', 'def']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_tenant(self):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
@@ -1860,7 +1860,9 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
     def test_serial(self):
     def test_serial(self):
-        params = {'asset_tag': ['A', 'B']}
+        params = {'serial': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'serial': ['a', 'b']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_asset_tag(self):
     def test_asset_tag(self):
@@ -3413,10 +3415,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_serial(self):
     def test_serial(self):
-        params = {'serial': 'ABC'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'serial': 'abc'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'serial': ['ABC', 'DEF']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'serial': ['abc', 'def']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_component_type(self):
     def test_component_type(self):
         params = {'component_type': 'dcim.interface'}
         params = {'component_type': 'dcim.interface'}

+ 1 - 3
netbox/dcim/views.py

@@ -560,9 +560,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class RackListView(generic.ObjectListView):
 class RackListView(generic.ObjectListView):
-    queryset = Rack.objects.prefetch_related(
-        'site', 'location', 'tenant', 'tenant_group', 'role', 'devices__device_type'
-    ).annotate(
+    queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
         device_count=count_related(Device, 'rack')
         device_count=count_related(Device, 'rack')
     )
     )
     filterset = filtersets.RackFilterSet
     filterset = filtersets.RackFilterSet

+ 3 - 2
netbox/extras/api/customfields.py

@@ -3,6 +3,7 @@ from rest_framework.fields import Field
 
 
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 
 
 
 
 #
 #
@@ -51,10 +52,10 @@ class CustomFieldsDataField(Field):
         for cf in self._get_custom_fields():
         for cf in self._get_custom_fields():
             value = cf.deserialize(obj.get(cf.name))
             value = cf.deserialize(obj.get(cf.name))
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
+                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
                 value = serializer(value, context=self.parent.context).data
                 value = serializer(value, context=self.parent.context).data
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
             elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
-                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
+                serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
                 value = serializer(value, many=True, context=self.parent.context).data
                 value = serializer(value, many=True, context=self.parent.context).data
             data[cf.name] = value
             data[cf.name] = value
 
 

+ 4 - 3
netbox/extras/api/serializers.py

@@ -15,6 +15,7 @@ from extras.utils import FeatureQuery
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
 from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.api.nested_serializers import NestedUserSerializer
 from users.api.nested_serializers import NestedUserSerializer
@@ -192,7 +193,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_parent(self, obj):
     def get_parent(self, obj):
-        serializer = get_serializer_for_model(obj.parent, prefix='Nested')
+        serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX)
         return serializer(obj.parent, context={'request': self.context['request']}).data
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 
 
 
@@ -242,7 +243,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_assigned_object(self, instance):
     def get_assigned_object(self, instance):
-        serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
+        serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(instance.assigned_object, context=context).data
         return serializer(instance.assigned_object, context=context).data
 
 
@@ -462,7 +463,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
             return None
             return None
 
 
         try:
         try:
-            serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
+            serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX)
         except SerializerNotFound:
         except SerializerNotFound:
             return obj.object_repr
             return obj.object_repr
         context = {
         context = {

+ 4 - 3
netbox/ipam/api/serializers.py

@@ -10,6 +10,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 from ipam.models import *
 from ipam.models import *
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.api.serializers import NetBoxModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -145,7 +146,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
     def get_interface(self, obj):
     def get_interface(self, obj):
         if obj.interface is None:
         if obj.interface is None:
             return None
             return None
-        serializer = get_serializer_for_model(obj.interface, prefix='Nested')
+        serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.interface, context=context).data
         return serializer(obj.interface, context=context).data
 
 
@@ -191,7 +192,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
     def get_scope(self, obj):
     def get_scope(self, obj):
         if obj.scope_id is None:
         if obj.scope_id is None:
             return None
             return None
-        serializer = get_serializer_for_model(obj.scope, prefix='Nested')
+        serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
 
 
         return serializer(obj.scope, context=context).data
         return serializer(obj.scope, context=context).data
@@ -375,7 +376,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
     def get_assigned_object(self, obj):
     def get_assigned_object(self, obj):
         if obj.assigned_object is None:
         if obj.assigned_object is None:
             return None
             return None
-        serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
+        serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.assigned_object, context=context).data
         return serializer(obj.assigned_object, context=context).data
 
 

+ 1 - 3
netbox/ipam/views.py

@@ -586,9 +586,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/iprange/ip_addresses.html'
     template_name = 'ipam/iprange/ip_addresses.html'
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
-        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
-            'vrf', 'role', 'tenant', 'tenant__group',
-        )
+        return parent.get_child_ips().restrict(request.user, 'view')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {

+ 2 - 1
netbox/netbox/api/viewsets/__init__.py

@@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet
 
 
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from .mixins import *
 from .mixins import *
 
 
@@ -60,7 +61,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
         if self.brief:
         if self.brief:
             logger.debug("Request is for 'brief' format; initializing nested serializer")
             logger.debug("Request is for 'brief' format; initializing nested serializer")
             try:
             try:
-                serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
+                serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
                 logger.debug(f"Using serializer {serializer}")
                 logger.debug(f"Using serializer {serializer}")
                 return serializer
                 return serializer
             except SerializerNotFound:
             except SerializerNotFound:

+ 3 - 254
netbox/netbox/constants.py

@@ -1,256 +1,5 @@
-from collections import OrderedDict
-from typing import Dict
-
-import circuits.filtersets
-import circuits.tables
-import dcim.filtersets
-import dcim.tables
-import ipam.filtersets
-import ipam.tables
-import tenancy.filtersets
-import tenancy.tables
-import virtualization.filtersets
-import virtualization.tables
-from circuits.models import Circuit, ProviderNetwork, Provider
-from dcim.models import (
-    Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
-)
-from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
-from tenancy.models import Contact, Tenant, ContactAssignment
-from utilities.utils import count_related
-from virtualization.models import Cluster, VirtualMachine
+# Prefix for nested serializers
+NESTED_SERIALIZER_PREFIX = 'Nested'
 
 
+# Max results per object type
 SEARCH_MAX_RESULTS = 15
 SEARCH_MAX_RESULTS = 15
-
-CIRCUIT_TYPES = OrderedDict(
-    (
-        ('provider', {
-            'queryset': Provider.objects.annotate(
-                count_circuits=count_related(Circuit, 'provider')
-            ),
-            'filterset': circuits.filtersets.ProviderFilterSet,
-            'table': circuits.tables.ProviderTable,
-            'url': 'circuits:provider_list',
-        }),
-        ('circuit', {
-            'queryset': Circuit.objects.prefetch_related(
-                'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
-            ),
-            'filterset': circuits.filtersets.CircuitFilterSet,
-            'table': circuits.tables.CircuitTable,
-            'url': 'circuits:circuit_list',
-        }),
-        ('providernetwork', {
-            'queryset': ProviderNetwork.objects.prefetch_related('provider'),
-            'filterset': circuits.filtersets.ProviderNetworkFilterSet,
-            'table': circuits.tables.ProviderNetworkTable,
-            'url': 'circuits:providernetwork_list',
-        }),
-    )
-)
-
-
-DCIM_TYPES = OrderedDict(
-    (
-        ('site', {
-            'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
-            'filterset': dcim.filtersets.SiteFilterSet,
-            'table': dcim.tables.SiteTable,
-            'url': 'dcim:site_list',
-        }),
-        ('rack', {
-            'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
-                device_count=count_related(Device, 'rack')
-            ),
-            'filterset': dcim.filtersets.RackFilterSet,
-            'table': dcim.tables.RackTable,
-            'url': 'dcim:rack_list',
-        }),
-        ('rackreservation', {
-            'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
-            'filterset': dcim.filtersets.RackReservationFilterSet,
-            'table': dcim.tables.RackReservationTable,
-            'url': 'dcim:rackreservation_list',
-        }),
-        ('location', {
-            'queryset': Location.objects.add_related_count(
-                Location.objects.add_related_count(
-                    Location.objects.all(),
-                    Device,
-                    'location',
-                    'device_count',
-                    cumulative=True
-                ),
-                Rack,
-                'location',
-                'rack_count',
-                cumulative=True
-            ).prefetch_related('site'),
-            'filterset': dcim.filtersets.LocationFilterSet,
-            'table': dcim.tables.LocationTable,
-            'url': 'dcim:location_list',
-        }),
-        ('devicetype', {
-            'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
-                instance_count=count_related(Device, 'device_type')
-            ),
-            'filterset': dcim.filtersets.DeviceTypeFilterSet,
-            'table': dcim.tables.DeviceTypeTable,
-            'url': 'dcim:devicetype_list',
-        }),
-        ('device', {
-            'queryset': Device.objects.prefetch_related(
-                'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4', 'primary_ip6',
-            ),
-            'filterset': dcim.filtersets.DeviceFilterSet,
-            'table': dcim.tables.DeviceTable,
-            'url': 'dcim:device_list',
-        }),
-        ('moduletype', {
-            'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
-                instance_count=count_related(Module, 'module_type')
-            ),
-            'filterset': dcim.filtersets.ModuleTypeFilterSet,
-            'table': dcim.tables.ModuleTypeTable,
-            'url': 'dcim:moduletype_list',
-        }),
-        ('module', {
-            'queryset': Module.objects.prefetch_related(
-                'module_type__manufacturer', 'device', 'module_bay',
-            ),
-            'filterset': dcim.filtersets.ModuleFilterSet,
-            'table': dcim.tables.ModuleTable,
-            'url': 'dcim:module_list',
-        }),
-        ('virtualchassis', {
-            'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
-                member_count=count_related(Device, 'virtual_chassis')
-            ),
-            'filterset': dcim.filtersets.VirtualChassisFilterSet,
-            'table': dcim.tables.VirtualChassisTable,
-            'url': 'dcim:virtualchassis_list',
-        }),
-        ('cable', {
-            'queryset': Cable.objects.all(),
-            'filterset': dcim.filtersets.CableFilterSet,
-            'table': dcim.tables.CableTable,
-            'url': 'dcim:cable_list',
-        }),
-        ('powerfeed', {
-            'queryset': PowerFeed.objects.all(),
-            'filterset': dcim.filtersets.PowerFeedFilterSet,
-            'table': dcim.tables.PowerFeedTable,
-            'url': 'dcim:powerfeed_list',
-        }),
-    )
-)
-
-IPAM_TYPES = OrderedDict(
-    (
-        ('vrf', {
-            'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
-            'filterset': ipam.filtersets.VRFFilterSet,
-            'table': ipam.tables.VRFTable,
-            'url': 'ipam:vrf_list',
-        }),
-        ('aggregate', {
-            'queryset': Aggregate.objects.prefetch_related('rir'),
-            'filterset': ipam.filtersets.AggregateFilterSet,
-            'table': ipam.tables.AggregateTable,
-            'url': 'ipam:aggregate_list',
-        }),
-        ('prefix', {
-            'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
-            'filterset': ipam.filtersets.PrefixFilterSet,
-            'table': ipam.tables.PrefixTable,
-            'url': 'ipam:prefix_list',
-        }),
-        ('ipaddress', {
-            'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
-            'filterset': ipam.filtersets.IPAddressFilterSet,
-            'table': ipam.tables.IPAddressTable,
-            'url': 'ipam:ipaddress_list',
-        }),
-        ('vlan', {
-            'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
-            'filterset': ipam.filtersets.VLANFilterSet,
-            'table': ipam.tables.VLANTable,
-            'url': 'ipam:vlan_list',
-        }),
-        ('asn', {
-            'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
-            'filterset': ipam.filtersets.ASNFilterSet,
-            'table': ipam.tables.ASNTable,
-            'url': 'ipam:asn_list',
-        }),
-        ('service', {
-            'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
-            'filterset': ipam.filtersets.ServiceFilterSet,
-            'table': ipam.tables.ServiceTable,
-            'url': 'ipam:service_list',
-        }),
-    )
-)
-
-TENANCY_TYPES = OrderedDict(
-    (
-        ('tenant', {
-            'queryset': Tenant.objects.prefetch_related('group'),
-            'filterset': tenancy.filtersets.TenantFilterSet,
-            'table': tenancy.tables.TenantTable,
-            'url': 'tenancy:tenant_list',
-        }),
-        ('contact', {
-            'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
-                assignment_count=count_related(ContactAssignment, 'contact')),
-            'filterset': tenancy.filtersets.ContactFilterSet,
-            'table': tenancy.tables.ContactTable,
-            'url': 'tenancy:contact_list',
-        }),
-    )
-)
-
-VIRTUALIZATION_TYPES = OrderedDict(
-    (
-        ('cluster', {
-            'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
-                device_count=count_related(Device, 'cluster'),
-                vm_count=count_related(VirtualMachine, 'cluster')
-            ),
-            'filterset': virtualization.filtersets.ClusterFilterSet,
-            'table': virtualization.tables.ClusterTable,
-            'url': 'virtualization:cluster_list',
-        }),
-        ('virtualmachine', {
-            'queryset': VirtualMachine.objects.prefetch_related(
-                'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
-            ),
-            'filterset': virtualization.filtersets.VirtualMachineFilterSet,
-            'table': virtualization.tables.VirtualMachineTable,
-            'url': 'virtualization:virtualmachine_list',
-        }),
-    )
-)
-
-SEARCH_TYPE_HIERARCHY = OrderedDict(
-    (
-        ("Circuits", CIRCUIT_TYPES),
-        ("DCIM", DCIM_TYPES),
-        ("IPAM", IPAM_TYPES),
-        ("Tenancy", TENANCY_TYPES),
-        ("Virtualization", VIRTUALIZATION_TYPES),
-    )
-)
-
-
-def build_search_types() -> Dict[str, Dict]:
-    result = dict()
-
-    for app_types in SEARCH_TYPE_HIERARCHY.values():
-        for name, items in app_types.items():
-            result[name] = items
-
-    return result
-
-
-SEARCH_TYPES = build_search_types()

+ 1 - 1
netbox/netbox/filtersets.py

@@ -125,7 +125,7 @@ class BaseFilterSet(django_filters.FilterSet):
             return {}
             return {}
 
 
         # Skip nonstandard lookup expressions
         # Skip nonstandard lookup expressions
-        if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
+        if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']:
             return {}
             return {}
 
 
         # Choose the lookup expression map based on the filter type
         # Choose the lookup expression map based on the filter type

+ 1 - 1
netbox/netbox/forms/__init__.py

@@ -1,6 +1,6 @@
 from django import forms
 from django import forms
 
 
-from netbox.constants import SEARCH_TYPE_HIERARCHY
+from netbox.search import SEARCH_TYPE_HIERARCHY
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
 from .base import *
 from .base import *
 
 

+ 261 - 0
netbox/netbox/search.py

@@ -0,0 +1,261 @@
+import circuits.filtersets
+import circuits.tables
+import dcim.filtersets
+import dcim.tables
+import ipam.filtersets
+import ipam.tables
+import tenancy.filtersets
+import tenancy.tables
+import virtualization.filtersets
+import wireless.tables
+import wireless.filtersets
+import virtualization.tables
+from circuits.models import Circuit, ProviderNetwork, Provider
+from dcim.models import (
+    Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
+    VirtualChassis,
+)
+from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
+from tenancy.models import Contact, Tenant, ContactAssignment
+from utilities.utils import count_related
+from wireless.models import WirelessLAN, WirelessLink
+from virtualization.models import Cluster, VirtualMachine
+
+CIRCUIT_TYPES = {
+    'provider': {
+        'queryset': Provider.objects.annotate(
+            count_circuits=count_related(Circuit, 'provider')
+        ),
+        'filterset': circuits.filtersets.ProviderFilterSet,
+        'table': circuits.tables.ProviderTable,
+        'url': 'circuits:provider_list',
+    },
+    'circuit': {
+        'queryset': Circuit.objects.prefetch_related(
+            'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
+        ),
+        'filterset': circuits.filtersets.CircuitFilterSet,
+        'table': circuits.tables.CircuitTable,
+        'url': 'circuits:circuit_list',
+    },
+    'providernetwork': {
+        'queryset': ProviderNetwork.objects.prefetch_related('provider'),
+        'filterset': circuits.filtersets.ProviderNetworkFilterSet,
+        'table': circuits.tables.ProviderNetworkTable,
+        'url': 'circuits:providernetwork_list',
+    },
+}
+
+DCIM_TYPES = {
+    'site': {
+        'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
+        'filterset': dcim.filtersets.SiteFilterSet,
+        'table': dcim.tables.SiteTable,
+        'url': 'dcim:site_list',
+    },
+    'rack': {
+        'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
+            device_count=count_related(Device, 'rack')
+        ),
+        'filterset': dcim.filtersets.RackFilterSet,
+        'table': dcim.tables.RackTable,
+        'url': 'dcim:rack_list',
+    },
+    'rackreservation': {
+        'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
+        'filterset': dcim.filtersets.RackReservationFilterSet,
+        'table': dcim.tables.RackReservationTable,
+        'url': 'dcim:rackreservation_list',
+    },
+    'location': {
+        'queryset': Location.objects.add_related_count(
+            Location.objects.add_related_count(
+                Location.objects.all(),
+                Device,
+                'location',
+                'device_count',
+                cumulative=True
+            ),
+            Rack,
+            'location',
+            'rack_count',
+            cumulative=True
+        ).prefetch_related('site'),
+        'filterset': dcim.filtersets.LocationFilterSet,
+        'table': dcim.tables.LocationTable,
+        'url': 'dcim:location_list',
+    },
+    'devicetype': {
+        'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
+            instance_count=count_related(Device, 'device_type')
+        ),
+        'filterset': dcim.filtersets.DeviceTypeFilterSet,
+        'table': dcim.tables.DeviceTypeTable,
+        'url': 'dcim:devicetype_list',
+    },
+    'device': {
+        'queryset': Device.objects.prefetch_related(
+            'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4',
+            'primary_ip6',
+        ),
+        'filterset': dcim.filtersets.DeviceFilterSet,
+        'table': dcim.tables.DeviceTable,
+        'url': 'dcim:device_list',
+    },
+    'moduletype': {
+        'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
+            instance_count=count_related(Module, 'module_type')
+        ),
+        'filterset': dcim.filtersets.ModuleTypeFilterSet,
+        'table': dcim.tables.ModuleTypeTable,
+        'url': 'dcim:moduletype_list',
+    },
+    'module': {
+        'queryset': Module.objects.prefetch_related(
+            'module_type__manufacturer', 'device', 'module_bay',
+        ),
+        'filterset': dcim.filtersets.ModuleFilterSet,
+        'table': dcim.tables.ModuleTable,
+        'url': 'dcim:module_list',
+    },
+    'virtualchassis': {
+        'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
+            member_count=count_related(Device, 'virtual_chassis')
+        ),
+        'filterset': dcim.filtersets.VirtualChassisFilterSet,
+        'table': dcim.tables.VirtualChassisTable,
+        'url': 'dcim:virtualchassis_list',
+    },
+    'cable': {
+        'queryset': Cable.objects.all(),
+        'filterset': dcim.filtersets.CableFilterSet,
+        'table': dcim.tables.CableTable,
+        'url': 'dcim:cable_list',
+    },
+    'powerfeed': {
+        'queryset': PowerFeed.objects.all(),
+        'filterset': dcim.filtersets.PowerFeedFilterSet,
+        'table': dcim.tables.PowerFeedTable,
+        'url': 'dcim:powerfeed_list',
+    },
+}
+
+IPAM_TYPES = {
+    'vrf': {
+        'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
+        'filterset': ipam.filtersets.VRFFilterSet,
+        'table': ipam.tables.VRFTable,
+        'url': 'ipam:vrf_list',
+    },
+    'aggregate': {
+        'queryset': Aggregate.objects.prefetch_related('rir'),
+        'filterset': ipam.filtersets.AggregateFilterSet,
+        'table': ipam.tables.AggregateTable,
+        'url': 'ipam:aggregate_list',
+    },
+    'prefix': {
+        'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
+        'filterset': ipam.filtersets.PrefixFilterSet,
+        'table': ipam.tables.PrefixTable,
+        'url': 'ipam:prefix_list',
+    },
+    'ipaddress': {
+        'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
+        'filterset': ipam.filtersets.IPAddressFilterSet,
+        'table': ipam.tables.IPAddressTable,
+        'url': 'ipam:ipaddress_list',
+    },
+    'vlan': {
+        'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
+        'filterset': ipam.filtersets.VLANFilterSet,
+        'table': ipam.tables.VLANTable,
+        'url': 'ipam:vlan_list',
+    },
+    'asn': {
+        'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
+        'filterset': ipam.filtersets.ASNFilterSet,
+        'table': ipam.tables.ASNTable,
+        'url': 'ipam:asn_list',
+    },
+    'service': {
+        'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
+        'filterset': ipam.filtersets.ServiceFilterSet,
+        'table': ipam.tables.ServiceTable,
+        'url': 'ipam:service_list',
+    },
+}
+
+TENANCY_TYPES = {
+    'tenant': {
+        'queryset': Tenant.objects.prefetch_related('group'),
+        'filterset': tenancy.filtersets.TenantFilterSet,
+        'table': tenancy.tables.TenantTable,
+        'url': 'tenancy:tenant_list',
+    },
+    'contact': {
+        'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
+            assignment_count=count_related(ContactAssignment, 'contact')),
+        'filterset': tenancy.filtersets.ContactFilterSet,
+        'table': tenancy.tables.ContactTable,
+        'url': 'tenancy:contact_list',
+    },
+}
+
+VIRTUALIZATION_TYPES = {
+    'cluster': {
+        'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
+        ),
+        'filterset': virtualization.filtersets.ClusterFilterSet,
+        'table': virtualization.tables.ClusterTable,
+        'url': 'virtualization:cluster_list',
+    },
+    'virtualmachine': {
+        'queryset': VirtualMachine.objects.prefetch_related(
+            'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
+        ),
+        'filterset': virtualization.filtersets.VirtualMachineFilterSet,
+        'table': virtualization.tables.VirtualMachineTable,
+        'url': 'virtualization:virtualmachine_list',
+    },
+}
+
+WIRELESS_TYPES = {
+    'wirelesslan': {
+        'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
+            interface_count=count_related(Interface, 'wireless_lans')
+        ),
+        'filterset': wireless.filtersets.WirelessLANFilterSet,
+        'table': wireless.tables.WirelessLANTable,
+        'url': 'wireless:wirelesslan_list',
+    },
+    'wirelesslink': {
+        'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'),
+        'filterset': wireless.filtersets.WirelessLinkFilterSet,
+        'table': wireless.tables.WirelessLinkTable,
+        'url': 'wireless:wirelesslink_list',
+    },
+}
+
+SEARCH_TYPE_HIERARCHY = {
+    'Circuits': CIRCUIT_TYPES,
+    'DCIM': DCIM_TYPES,
+    'IPAM': IPAM_TYPES,
+    'Tenancy': TENANCY_TYPES,
+    'Virtualization': VIRTUALIZATION_TYPES,
+    'Wireless': WIRELESS_TYPES,
+}
+
+
+def build_search_types():
+    result = dict()
+
+    for app_types in SEARCH_TYPE_HIERARCHY.values():
+        for name, items in app_types.items():
+            result[name] = items
+
+    return result
+
+
+SEARCH_TYPES = build_search_types()

+ 9 - 8
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.2.6'
+VERSION = '3.2.7'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -476,13 +476,6 @@ if SENTRY_ENABLED:
 # Django social auth
 # Django social auth
 #
 #
 
 
-# Load all SOCIAL_AUTH_* settings from the user configuration
-for param in dir(configuration):
-    if param.startswith('SOCIAL_AUTH_'):
-        globals()[param] = getattr(configuration, param)
-
-SOCIAL_AUTH_JSONFIELD_ENABLED = True
-
 SOCIAL_AUTH_PIPELINE = (
 SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_uid',
     'social_core.pipeline.social_auth.social_uid',
@@ -496,6 +489,14 @@ SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.user.user_details',
     'social_core.pipeline.user.user_details',
 )
 )
 
 
+# Load all SOCIAL_AUTH_* settings from the user configuration
+for param in dir(configuration):
+    if param.startswith('SOCIAL_AUTH_'):
+        globals()[param] = getattr(configuration, param)
+
+# Force usage of PostgreSQL's JSONB field for extra data
+SOCIAL_AUTH_JSONFIELD_ENABLED = True
+
 
 
 #
 #
 # Django Prometheus
 # Django Prometheus

+ 2 - 1
netbox/netbox/views/__init__.py

@@ -22,8 +22,9 @@ from dcim.models import (
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from extras.tables import ObjectChangeTable
 from extras.tables import ObjectChangeTable
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
-from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
+from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.forms import SearchForm
 from netbox.forms import SearchForm
+from netbox.search import SEARCH_TYPES
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
 from wireless.models import WirelessLAN, WirelessLink

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 3 - 1
netbox/project-static/src/buttons/reslug.ts

@@ -38,7 +38,9 @@ export function initReslug(): void {
     slugLength = Number(slugLengthAttr);
     slugLength = Number(slugLengthAttr);
   }
   }
   sourceField.addEventListener('blur', () => {
   sourceField.addEventListener('blur', () => {
-    slugField.value = slugify(sourceField.value, slugLength);
+    if (!slugField.value) {
+      slugField.value = slugify(sourceField.value, slugLength);
+    }
   });
   });
   slugButton.addEventListener('click', () => {
   slugButton.addEventListener('click', () => {
     slugField.value = slugify(sourceField.value, slugLength);
     slugField.value = slugify(sourceField.value, slugLength);

+ 1 - 39
netbox/project-static/src/forms/elements.ts

@@ -1,32 +1,4 @@
-import { getElements, scrollTo, isTruthy } from '../util';
-
-/**
- * When editing an object, it is sometimes desirable to customize the form action *without*
- * overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want
- * to use the `formaction` attribute on that element because it will be included on the form even
- * if the button isn't clicked.
- *
- * @example
- * ```html
- * <button type="button" return-url="/special-url/">
- *   Save & Continue
- * </button>
- * ```
- *
- * @param event Click event.
- */
-function handleSubmitWithReturnUrl(event: MouseEvent): void {
-  const element = event.target as HTMLElement;
-  if (element.tagName === 'BUTTON') {
-    const button = element as HTMLButtonElement;
-    const action = button.getAttribute('return-url');
-    const form = button.form;
-    if (form !== null && isTruthy(action)) {
-      form.action = action;
-      form.submit();
-    }
-  }
-}
+import { getElements, scrollTo } from '../util';
 
 
 function handleFormSubmit(event: Event, form: HTMLFormElement): void {
 function handleFormSubmit(event: Event, form: HTMLFormElement): void {
   // Track the names of each invalid field.
   // Track the names of each invalid field.
@@ -57,15 +29,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
   }
   }
 }
 }
 
 
-/**
- * Attach event listeners to form buttons with the `return-url` attribute present.
- */
-function initReturnUrlSubmitButtons(): void {
-  for (const button of getElements<HTMLButtonElement>('button[return-url]')) {
-    button.addEventListener('click', handleSubmitWithReturnUrl);
-  }
-}
-
 /**
 /**
  * Attach an event listener to each form's submitter (button[type=submit]). When called, the
  * Attach an event listener to each form's submitter (button[type=submit]). When called, the
  * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
  * callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -82,5 +45,4 @@ export function initFormElements(): void {
       submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
       submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
     }
     }
   }
   }
-  initReturnUrlSubmitButtons();
 }
 }

+ 0 - 1
netbox/project-static/src/select/api/apiSelect.ts

@@ -411,7 +411,6 @@ export class APISelect {
     } finally {
     } finally {
       this.setOptionStyles();
       this.setOptionStyles();
       this.enable();
       this.enable();
-      this.slim.slim.search.input.focus();
       this.base.dispatchEvent(this.loadEvent);
       this.base.dispatchEvent(this.loadEvent);
     }
     }
   }
   }

+ 0 - 14
netbox/templates/circuits/circuittermination_edit.html

@@ -48,17 +48,3 @@
     {% render_field form.description %}
     {% render_field form.description %}
   </div>
   </div>
 {% endblock %}
 {% endblock %}
-
-{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
-{% block buttons %}
-    <a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
-    {% if object.pk %}
-        <button type="submit" name="_update" class="btn btn-primary">
-            Save
-        </button>
-    {% else %}
-        <button type="submit" name="_create" class="btn btn-primary">
-            Create
-        </button>
-    {% endif %}
-{% endblock buttons %}

+ 0 - 11
netbox/templates/dcim/interface_edit.html

@@ -91,14 +91,3 @@
       </div>
       </div>
     {% endif %}
     {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block buttons %}
-    <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-    {% if object.pk %}
-        <button type="button" return-url="?return_url={% url 'dcim:interface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
-        <button type="submit" name="_update" class="btn btn-primary">Save</button>
-    {% else %}
-        <button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
-        <button type="submit" name="_create" class="btn btn-primary">Create</button>
-    {% endif %}
-{% endblock %}

+ 1 - 1
netbox/templates/generic/bulk_delete.html

@@ -36,8 +36,8 @@ Context:
           {{ field }}
           {{ field }}
         {% endfor %}
         {% endfor %}
         <div class="text-end">
         <div class="text-end">
-          <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
           <button type="submit" name="_confirm" class="btn btn-danger">Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
           <button type="submit" name="_confirm" class="btn btn-danger">Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
+          <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
         </div>
         </div>
       </form>
       </form>
     </div>
     </div>

+ 1 - 1
netbox/templates/generic/bulk_edit.html

@@ -118,8 +118,8 @@ Context:
               </div>
               </div>
 
 
               <div class="text-end">
               <div class="text-end">
-                <a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
                 <button type="submit" name="_apply" class="btn btn-sm btn-primary">Apply</button>
                 <button type="submit" name="_apply" class="btn btn-sm btn-primary">Apply</button>
+                <a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>

+ 6 - 6
netbox/templates/generic/bulk_import.html

@@ -44,12 +44,12 @@ Context:
                     </div>
                     </div>
                   </div>
                   </div>
                   <div class="form-group">
                   <div class="form-group">
-                      <div class="col col-md-12 text-end">
-                          {% if return_url %}
-                              <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-                          {% endif %}
-                          <button type="submit" class="btn btn-primary">Submit</button>
-                      </div>
+                    <div class="col col-md-12 text-end">
+                      <button type="submit" class="btn btn-primary">Submit</button>
+                      {% if return_url %}
+                        <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+                      {% endif %}
+                    </div>
                   </div>
                   </div>
                 </form>
                 </form>
                 {% if fields %}
                 {% if fields %}

+ 1 - 1
netbox/templates/generic/bulk_remove.html

@@ -23,8 +23,8 @@
       {{ field }}
       {{ field }}
     {% endfor %}
     {% endfor %}
     <div class="text-center">
     <div class="text-center">
-      <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
       <button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
       <button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
+      <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
     </div>
     </div>
   </form>
   </form>
 </div>
 </div>

+ 1 - 1
netbox/templates/generic/bulk_rename.html

@@ -34,11 +34,11 @@
                 </div>
                 </div>
             </div>
             </div>
             <div class="col col-md-12 my-3 text-end">
             <div class="col col-md-12 my-3 text-end">
-                <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
                 <button type="submit" name="_preview" class="btn btn-primary">Preview</button>
                 <button type="submit" name="_preview" class="btn btn-primary">Preview</button>
                 {% if '_preview' in request.POST and not form.errors %}
                 {% if '_preview' in request.POST and not form.errors %}
                     <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
                     <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
                 {% endif %}
                 {% endif %}
+                <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
             </div>
             </div>
         </form>
         </form>
     </div>
     </div>

+ 18 - 27
netbox/templates/generic/confirmation_form.html

@@ -2,33 +2,24 @@
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
-    <div class="row mt-5">
-
-        <div class="col col-md-6 offset-md-3">
-
-            <form action="" method="post" class="form">
-                {% csrf_token %}
-                {% for field in form.hidden_fields %}
-                    {{ field }}
-                {% endfor %}
-
-                <div class="card border-danger">
-                    <h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5>
-            
-                    <div class="card-body">
-                        {% block message %}<p>Are you sure?</p>{% endblock %}
-                    </div>
-
-                    <div class="card-footer text-end">
-                        <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
-                        <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
-                    </div>
-
-                </div>
-
-            </form>
-
+  <div class="row mt-5">
+    <div class="col col-md-6 offset-md-3">
+      <form action="" method="post" class="form">
+        {% csrf_token %}
+        {% for field in form.hidden_fields %}
+          {{ field }}
+        {% endfor %}
+        <div class="card border-danger">
+          <h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5>
+          <div class="card-body">
+            {% block message %}<p>Are you sure?</p>{% endblock %}
+          </div>
+          <div class="card-footer text-end">
+            <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
+            <a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
+          </div>
         </div>
         </div>
-
+      </form>
     </div>
     </div>
+  </div>
 {% endblock %}
 {% endblock %}

+ 4 - 4
netbox/templates/generic/object_edit.html

@@ -94,19 +94,19 @@ Context:
 
 
         <div class="text-end my-3">
         <div class="text-end my-3">
           {% block buttons %}
           {% block buttons %}
-            <a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
             {% if object.pk %}
             {% if object.pk %}
               <button type="submit" name="_update" class="btn btn-primary">
               <button type="submit" name="_update" class="btn btn-primary">
                 Save
                 Save
               </button>
               </button>
             {% else %}
             {% else %}
-              <button type="submit" name="_addanother" class="btn btn-outline-primary">
-                Create & Add Another
-              </button>
               <button type="submit" name="_create" class="btn btn-primary">
               <button type="submit" name="_create" class="btn btn-primary">
                 Create
                 Create
               </button>
               </button>
+              <button type="submit" name="_addanother" class="btn btn-outline-primary">
+                Create & Add Another
+              </button>
             {% endif %}
             {% endif %}
+            <a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
           {% endblock buttons %}
           {% endblock buttons %}
         </div>
         </div>
       </form>
       </form>

+ 15 - 15
netbox/templates/generic/object_import.html

@@ -5,19 +5,19 @@
 {% block title %}{{ obj_type|bettertitle }} Import{% endblock %}
 {% block title %}{{ obj_type|bettertitle }} Import{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<div class="row mb-3">
-	<div class="col col-md-12 col-xl-8 offset-xl-2">
-		<form action="" method="post" class="form form-horizontal">
-		    {% csrf_token %}
-		    {% render_form form %}
-            <div class="col col-md-12 text-end">
-                {% if return_url %}
-                    <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-                {% endif %}
-                <button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
-                <button type="submit" name="_create" class="btn btn-primary">Submit</button>
-            </div>
-		</form>
-	</div>
-</div>
+  <div class="row mb-3">
+    <div class="col col-md-12 col-xl-8 offset-xl-2">
+      <form action="" method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% render_form form %}
+        <div class="col col-md-12 text-end">
+          <button type="submit" name="_create" class="btn btn-primary">Submit</button>
+          <button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
+          {% if return_url %}
+            <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+          {% endif %}
+        </div>
+      </form>
+    </div>
+  </div>
 {% endblock content %}
 {% endblock content %}

+ 0 - 11
netbox/templates/virtualization/vminterface_edit.html

@@ -55,14 +55,3 @@
       </div>
       </div>
     {% endif %}
     {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block buttons %}
-  <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-  {% if object.pk %}
-    <button type="button" return-url="?return_url={% url 'virtualization:vminterface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
-    <button type="submit" name="_update" class="btn btn-primary">Save</button>
-  {% else %}
-    <button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
-    <button type="submit" name="_create" class="btn btn-primary">Create</button>
-  {% endif %}
-{% endblock %}

+ 2 - 1
netbox/tenancy/api/serializers.py

@@ -4,6 +4,7 @@ from rest_framework import serializers
 
 
 from netbox.api import ChoiceField, ContentTypeField
 from netbox.api import ChoiceField, ContentTypeField
 from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import *
 from tenancy.models import *
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
@@ -108,6 +109,6 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_object(self, instance):
     def get_object(self, instance):
-        serializer = get_serializer_for_model(instance.content_type.model_class(), prefix='Nested')
+        serializer = get_serializer_for_model(instance.content_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(instance.object, context=context).data
         return serializer(instance.object, context=context).data

+ 8 - 2
netbox/users/views.py

@@ -20,7 +20,7 @@ from netbox.authentication import get_auth_backend_display
 from netbox.config import get_config
 from netbox.config import get_config
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
-from .models import Token
+from .models import Token, UserConfig
 
 
 
 
 #
 #
@@ -69,7 +69,13 @@ class LoginView(View):
             # Authenticate user
             # Authenticate user
             auth_login(request, form.get_user())
             auth_login(request, form.get_user())
             logger.info(f"User {request.user} successfully authenticated")
             logger.info(f"User {request.user} successfully authenticated")
-            messages.info(request, "Logged in as {}.".format(request.user))
+            messages.info(request, f"Logged in as {request.user}.")
+
+            # Ensure the user has a UserConfig defined. (This should normally be handled by
+            # create_userconfig() on user creation.)
+            if not hasattr(request.user, 'config'):
+                config = get_config()
+                UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
 
 
             return self.redirect_to_next(request, logger)
             return self.redirect_to_next(request, logger)
 
 

+ 2 - 0
netbox/utilities/templatetags/builtins/filters.py

@@ -144,6 +144,8 @@ def render_markdown(value):
 
 
         {{ md_source_text|markdown }}
         {{ md_source_text|markdown }}
     """
     """
+    if not value:
+        return ''
 
 
     # Render Markdown
     # Render Markdown
     html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
     html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])

+ 2 - 0
netbox/virtualization/views.py

@@ -471,6 +471,7 @@ class VMInterfaceBulkImportView(generic.BulkImportView):
 
 
 class VMInterfaceBulkEditView(generic.BulkEditView):
 class VMInterfaceBulkEditView(generic.BulkEditView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
+    filterset = filtersets.VMInterfaceFilterSet
     table = tables.VMInterfaceTable
     table = tables.VMInterfaceTable
     form = forms.VMInterfaceBulkEditForm
     form = forms.VMInterfaceBulkEditForm
 
 
@@ -482,6 +483,7 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
 
 
 class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
 class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
+    filterset = filtersets.VMInterfaceFilterSet
     table = tables.VMInterfaceTable
     table = tables.VMInterfaceTable
 
 
 
 

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików