Просмотр исходного кода

Merge branch 'develop' into feature

jeremystretch 3 лет назад
Родитель
Сommit
abf11fbcb8
39 измененных файлов с 410 добавлено и 436 удалено
  1. 1 1
      docs/administration/authentication/overview.md
  2. 1 1
      docs/models/extras/webhook.md
  3. 22 1
      docs/release-notes/version-3.2.md
  4. 7 6
      netbox/dcim/api/serializers.py
  5. 3 2
      netbox/dcim/api/views.py
  6. 6 3
      netbox/dcim/filtersets.py
  7. 2 2
      netbox/dcim/forms/filtersets.py
  8. 2 1
      netbox/dcim/svg/racks.py
  9. 11 9
      netbox/dcim/tests/test_filtersets.py
  10. 3 2
      netbox/extras/api/customfields.py
  11. 4 3
      netbox/extras/api/serializers.py
  12. 5 4
      netbox/ipam/api/serializers.py
  13. 2 1
      netbox/netbox/api/viewsets/__init__.py
  14. 3 254
      netbox/netbox/constants.py
  15. 1 1
      netbox/netbox/filtersets.py
  16. 1 1
      netbox/netbox/forms/__init__.py
  17. 261 0
      netbox/netbox/search.py
  18. 8 7
      netbox/netbox/settings.py
  19. 2 1
      netbox/netbox/views/__init__.py
  20. 0 0
      netbox/project-static/dist/netbox.js
  21. 0 0
      netbox/project-static/dist/netbox.js.map
  22. 3 1
      netbox/project-static/src/buttons/reslug.ts
  23. 1 39
      netbox/project-static/src/forms/elements.ts
  24. 0 1
      netbox/project-static/src/select/api/apiSelect.ts
  25. 0 14
      netbox/templates/circuits/circuittermination_edit.html
  26. 0 11
      netbox/templates/dcim/interface_edit.html
  27. 1 1
      netbox/templates/generic/bulk_delete.html
  28. 1 1
      netbox/templates/generic/bulk_edit.html
  29. 6 6
      netbox/templates/generic/bulk_import.html
  30. 1 1
      netbox/templates/generic/bulk_remove.html
  31. 1 1
      netbox/templates/generic/bulk_rename.html
  32. 18 27
      netbox/templates/generic/confirmation_form.html
  33. 4 4
      netbox/templates/generic/object_edit.html
  34. 15 15
      netbox/templates/generic/object_import.html
  35. 0 11
      netbox/templates/virtualization/vminterface_edit.html
  36. 2 1
      netbox/tenancy/api/serializers.py
  37. 8 2
      netbox/users/views.py
  38. 2 0
      netbox/utilities/templatetags/builtins/filters.py
  39. 2 0
      netbox/virtualization/views.py

+ 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
 
 

+ 22 - 1
docs/release-notes/version-3.2.md

@@ -1,6 +1,27 @@
 # NetBox v3.2
 # NetBox v3.2
 
 
-## v3.2.7 (FUTURE)
+## v3.2.8 (FUTURE)
+
+---
+
+## 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
 
 
 ---
 ---
 
 

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

@@ -19,6 +19,7 @@ from netbox.api.serializers import (
     WritableNestedSerializer,
     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
@@ -57,7 +58,7 @@ class CabledObjectSerializer(serializers.ModelSerializer):
             return []
             return []
 
 
         # Return serialized peer termination objects
         # Return serialized peer termination objects
-        serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
+        serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.link_peers, context=context, many=True).data
         return serializer(obj.link_peers, context=context, many=True).data
 
 
@@ -84,7 +85,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
         Return the appropriate serializer for the type of connected object.
         Return the appropriate serializer for the type of connected object.
         """
         """
         if endpoints := obj.connected_endpoints:
         if endpoints := obj.connected_endpoints:
-            serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
+            serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             return serializer(endpoints, many=True, context=context).data
             return serializer(endpoints, many=True, context=context).data
 
 
@@ -572,7 +573,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
 
 
@@ -968,7 +969,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
 
 
@@ -1037,7 +1038,7 @@ class CableTerminationSerializer(NetBoxModelSerializer):
 
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_termination(self, obj):
     def get_termination(self, obj):
-        serializer = get_serializer_for_model(obj.termination, prefix='Nested')
+        serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return serializer(obj.termination, context=context).data
         return serializer(obj.termination, context=context).data
 
 
@@ -1053,7 +1054,7 @@ class CablePathSerializer(serializers.ModelSerializer):
     def get_path(self, obj):
     def get_path(self, obj):
         ret = []
         ret = []
         for nodes in obj.path_objects:
         for nodes in obj.path_objects:
-            serializer = get_serializer_for_model(nodes[0], prefix='Nested')
+            serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
             context = {'request': self.context['request']}
             context = {'request': self.context['request']}
             ret.append(serializer(nodes, context=context, many=True).data)
             ret.append(serializer(nodes, context=context, many=True).data)
         return ret
         return ret

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

@@ -24,6 +24,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
@@ -65,7 +66,7 @@ class PathEndpointMixin(object):
         # Serialize path objects, iterating over each three-tuple in the path
         # Serialize path objects, iterating over each three-tuple in the path
         for near_end, cable, far_end in obj.trace():
         for near_end, cable, far_end in obj.trace():
             if near_end is not None:
             if near_end is not None:
-                serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
+                serializer_a = get_serializer_for_model(near_end[0], prefix=NESTED_SERIALIZER_PREFIX)
                 near_end = serializer_a(near_end, many=True, context={'request': request}).data
                 near_end = serializer_a(near_end, many=True, context={'request': request}).data
             else:
             else:
                 # Path is split; stop here
                 # Path is split; stop here
@@ -73,7 +74,7 @@ class PathEndpointMixin(object):
             if cable is not None:
             if cable is not None:
                 cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
                 cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
             if far_end is not None:
             if far_end is not None:
-                serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
+                serializer_b = get_serializer_for_model(far_end[0], prefix=NESTED_SERIALIZER_PREFIX)
                 far_end = serializer_b(far_end, many=True, context={'request': request}).data
                 far_end = serializer_b(far_end, many=True, context={'request': request}).data
 
 
             path.append((near_end, cable, far_end))
             path.append((near_end, cable, far_end))

+ 6 - 3
netbox/dcim/filtersets.py

@@ -312,7 +312,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'
     )
     )
 
 
@@ -1007,10 +1007,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():
@@ -1411,7 +1414,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

@@ -998,8 +998,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,

+ 2 - 1
netbox/dcim/svg/racks.py

@@ -163,8 +163,9 @@ class RackElevationSVG:
 
 
         # Embed device type image if provided
         # Embed device type image if provided
         if self.include_images and image:
         if self.include_images and image:
+            url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
             image = Image(
             image = Image(
-                href=f'{self.base_url}{image.url}',
+                href=url,
                 insert=coords,
                 insert=coords,
                 size=size,
                 size=size,
                 class_=f'device-image{css_extra}'
                 class_=f'device-image{css_extra}'

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

@@ -498,10 +498,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]
@@ -1864,7 +1864,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):
@@ -3520,10 +3522,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'}

+ 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.exceptions import SerializerNotFound
 from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 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
@@ -193,7 +194,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
 
 
 
 
@@ -243,7 +244,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
 
 
@@ -469,7 +470,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 = {

+ 5 - 4
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.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields 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
@@ -148,7 +149,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
 
 
@@ -194,7 +195,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
@@ -378,7 +379,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
 
 
@@ -485,6 +486,6 @@ class L2VPNTerminationSerializer(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, prefix='Nested')
+        serializer = get_serializer_for_model(instance.assigned_object, 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

+ 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 utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 from .mixins import *
 from .mixins import *
@@ -61,7 +62,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()

+ 8 - 7
netbox/netbox/settings.py

@@ -478,13 +478,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',
@@ -498,6 +491,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

@@ -21,8 +21,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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 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

@@ -56,17 +56,3 @@
     {% render_custom_fields form %}
     {% render_custom_fields form %}
   </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

@@ -99,14 +99,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.fields import ChoiceField, ContentTypeField
 from netbox.api.fields 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
 from .tables import TokenTable
 from .tables import TokenTable
 
 
 
 
@@ -70,7 +70,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
 
 
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов