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

Merge branch 'feature' into 9856-strawberry-2

Arthur 2 лет назад
Родитель
Сommit
cc5703c9dd

+ 17 - 0
docs/customization/custom-scripts.md

@@ -304,6 +304,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
 
 * `model` - The model class
 * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
+* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
 * `null_option` - A label representing a "null" or empty choice (optional)
 
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
@@ -331,6 +332,22 @@ site = ObjectVar(
 )
 ```
 
+#### Context Variables
+
+Custom context variables can be passed to override the default attribute names or to display additional information, such as a parent object.
+
+| Name          | Default         | Description                                                                  |
+|---------------|-----------------|------------------------------------------------------------------------------|
+| `value`       | `"id"`          | The attribute which contains the option's value                              |
+| `label`       | `"display"`     | The attribute used as the option's human-friendly label                      |
+| `description` | `"description"` | The attribute to use as a description                                        |
+| `depth`[^1]   | `"_depth"`      | The attribute which indicates an object's depth within a recursive hierarchy |
+| `disabled`    | --              | The attribute which, if true, signifies that the option should be disabled   |
+| `parent`      | --              | The attribute which represents the object's parent object                    |
+| `count`[^1]   | --              | The attribute which contains a numeric count of related objects              |
+
+[^1]: The value of this attribute must be a positive integer
+
 ### MultiObjectVar
 
 Similar to `ObjectVar`, but allows for the selection of multiple objects.

+ 4 - 0
docs/release-notes/version-4.0.md

@@ -5,6 +5,7 @@
 ### Breaking Changes
 
 * The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.)
+* The legacy reports functionality has been dropped. Reports will be automatically converted to custom scripts on upgrade.
 
 ### New Features
 
@@ -15,6 +16,8 @@ The NetBox user interface has been completely refreshed and updated.
 ### Enhancements
 
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
+* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown fields
+* [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection fields when modifying a parent selection
 * [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0
 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
@@ -23,6 +26,7 @@ The NetBox user interface has been completely refreshed and updated.
 ### Other Changes
 
 * [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it)
+* [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports
 * [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses a custom User model rather than the stock model provided by Django
 * [#13647](https://github.com/netbox-community/netbox/issues/13647) - Squash all database migrations prior to v3.7
 * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`)

+ 1 - 0
mkdocs.yml

@@ -292,6 +292,7 @@ nav:
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'
+        - Version 4.0: 'release-notes/version-4.0.md'
         - Version 3.7: 'release-notes/version-3.7.md'
         - Version 3.6: 'release-notes/version-3.6.md'
         - Version 3.5: 'release-notes/version-3.5.md'

+ 6 - 11
netbox/circuits/api/views.py

@@ -21,7 +21,7 @@ class CircuitsRootView(APIRootView):
 #
 
 class ProviderViewSet(NetBoxModelViewSet):
-    queryset = Provider.objects.prefetch_related('asns', 'tags').annotate(
+    queryset = Provider.objects.annotate(
         circuit_count=count_related(Circuit, 'provider')
     )
     serializer_class = serializers.ProviderSerializer
@@ -33,7 +33,7 @@ class ProviderViewSet(NetBoxModelViewSet):
 #
 
 class CircuitTypeViewSet(NetBoxModelViewSet):
-    queryset = CircuitType.objects.prefetch_related('tags').annotate(
+    queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
     )
     serializer_class = serializers.CircuitTypeSerializer
@@ -45,9 +45,7 @@ class CircuitTypeViewSet(NetBoxModelViewSet):
 #
 
 class CircuitViewSet(NetBoxModelViewSet):
-    queryset = Circuit.objects.prefetch_related(
-        'type', 'tenant', 'provider', 'provider_account', 'termination_a', 'termination_z'
-    ).prefetch_related('tags')
+    queryset = Circuit.objects.all()
     serializer_class = serializers.CircuitSerializer
     filterset_class = filtersets.CircuitFilterSet
 
@@ -57,12 +55,9 @@ class CircuitViewSet(NetBoxModelViewSet):
 #
 
 class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
-    queryset = CircuitTermination.objects.prefetch_related(
-        'circuit', 'site', 'provider_network', 'cable__terminations'
-    )
+    queryset = CircuitTermination.objects.all()
     serializer_class = serializers.CircuitTerminationSerializer
     filterset_class = filtersets.CircuitTerminationFilterSet
-    brief_prefetch_fields = ['circuit']
 
 
 #
@@ -70,7 +65,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 #
 
 class ProviderAccountViewSet(NetBoxModelViewSet):
-    queryset = ProviderAccount.objects.prefetch_related('provider', 'tags')
+    queryset = ProviderAccount.objects.all()
     serializer_class = serializers.ProviderAccountSerializer
     filterset_class = filtersets.ProviderAccountFilterSet
 
@@ -80,6 +75,6 @@ class ProviderAccountViewSet(NetBoxModelViewSet):
 #
 
 class ProviderNetworkViewSet(NetBoxModelViewSet):
-    queryset = ProviderNetwork.objects.prefetch_related('tags')
+    queryset = ProviderNetwork.objects.all()
     serializer_class = serializers.ProviderNetworkSerializer
     filterset_class = filtersets.ProviderNetworkFilterSet

+ 2 - 2
netbox/core/api/views.py

@@ -44,7 +44,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
 
 
 class DataFileViewSet(NetBoxReadOnlyModelViewSet):
-    queryset = DataFile.objects.defer('data').prefetch_related('source')
+    queryset = DataFile.objects.defer('data')
     serializer_class = serializers.DataFileSerializer
     filterset_class = filtersets.DataFileFilterSet
 
@@ -53,6 +53,6 @@ class JobViewSet(ReadOnlyModelViewSet):
     """
     Retrieve a list of job results
     """
-    queryset = Job.objects.prefetch_related('user')
+    queryset = Job.objects.all()
     serializer_class = serializers.JobSerializer
     filterset_class = filtersets.JobFilterSet

+ 43 - 68
netbox/dcim/api/views.py

@@ -103,7 +103,7 @@ class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
         'region',
         'site_count',
         cumulative=True
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.RegionSerializer
     filterset_class = filtersets.RegionFilterSet
 
@@ -119,7 +119,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
         'group',
         'site_count',
         cumulative=True
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.SiteGroupSerializer
     filterset_class = filtersets.SiteGroupFilterSet
 
@@ -129,9 +129,7 @@ class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class SiteViewSet(NetBoxModelViewSet):
-    queryset = Site.objects.prefetch_related(
-        'region', 'tenant', 'asns', 'tags'
-    ).annotate(
+    queryset = Site.objects.annotate(
         device_count=count_related(Device, 'site'),
         rack_count=count_related(Rack, 'site'),
         prefix_count=count_related(Prefix, 'site'),
@@ -160,7 +158,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
         'location',
         'rack_count',
         cumulative=True
-    ).prefetch_related('site', 'tags')
+    )
     serializer_class = serializers.LocationSerializer
     filterset_class = filtersets.LocationFilterSet
 
@@ -170,7 +168,7 @@ class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class RackRoleViewSet(NetBoxModelViewSet):
-    queryset = RackRole.objects.prefetch_related('tags').annotate(
+    queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
     )
     serializer_class = serializers.RackRoleSerializer
@@ -182,9 +180,7 @@ class RackRoleViewSet(NetBoxModelViewSet):
 #
 
 class RackViewSet(NetBoxModelViewSet):
-    queryset = Rack.objects.prefetch_related(
-        'site', 'location', 'role', 'tenant', 'tags'
-    ).annotate(
+    queryset = Rack.objects.annotate(
         device_count=count_related(Device, 'rack'),
         powerfeed_count=count_related(PowerFeed, 'rack')
     )
@@ -249,7 +245,7 @@ class RackViewSet(NetBoxModelViewSet):
 #
 
 class RackReservationViewSet(NetBoxModelViewSet):
-    queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
+    queryset = RackReservation.objects.all()
     serializer_class = serializers.RackReservationSerializer
     filterset_class = filtersets.RackReservationFilterSet
 
@@ -259,7 +255,7 @@ class RackReservationViewSet(NetBoxModelViewSet):
 #
 
 class ManufacturerViewSet(NetBoxModelViewSet):
-    queryset = Manufacturer.objects.prefetch_related('tags').annotate(
+    queryset = Manufacturer.objects.annotate(
         devicetype_count=count_related(DeviceType, 'manufacturer'),
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         platform_count=count_related(Platform, 'manufacturer')
@@ -273,21 +269,17 @@ class ManufacturerViewSet(NetBoxModelViewSet):
 #
 
 class DeviceTypeViewSet(NetBoxModelViewSet):
-    queryset = DeviceType.objects.prefetch_related('manufacturer', 'default_platform', 'tags').annotate(
+    queryset = DeviceType.objects.annotate(
         device_count=count_related(Device, 'device_type')
     )
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filtersets.DeviceTypeFilterSet
-    brief_prefetch_fields = ['manufacturer']
 
 
 class ModuleTypeViewSet(NetBoxModelViewSet):
-    queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate(
-        # module_count=count_related(Module, 'module_type')
-    )
+    queryset = ModuleType.objects.all()
     serializer_class = serializers.ModuleTypeSerializer
     filterset_class = filtersets.ModuleTypeFilterSet
-    brief_prefetch_fields = ['manufacturer']
 
 
 #
@@ -295,61 +287,61 @@ class ModuleTypeViewSet(NetBoxModelViewSet):
 #
 
 class ConsolePortTemplateViewSet(NetBoxModelViewSet):
-    queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = ConsolePortTemplate.objects.all()
     serializer_class = serializers.ConsolePortTemplateSerializer
     filterset_class = filtersets.ConsolePortTemplateFilterSet
 
 
 class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet):
-    queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = ConsoleServerPortTemplate.objects.all()
     serializer_class = serializers.ConsoleServerPortTemplateSerializer
     filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
 
 
 class PowerPortTemplateViewSet(NetBoxModelViewSet):
-    queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = PowerPortTemplate.objects.all()
     serializer_class = serializers.PowerPortTemplateSerializer
     filterset_class = filtersets.PowerPortTemplateFilterSet
 
 
 class PowerOutletTemplateViewSet(NetBoxModelViewSet):
-    queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = PowerOutletTemplate.objects.all()
     serializer_class = serializers.PowerOutletTemplateSerializer
     filterset_class = filtersets.PowerOutletTemplateFilterSet
 
 
 class InterfaceTemplateViewSet(NetBoxModelViewSet):
-    queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = InterfaceTemplate.objects.all()
     serializer_class = serializers.InterfaceTemplateSerializer
     filterset_class = filtersets.InterfaceTemplateFilterSet
 
 
 class FrontPortTemplateViewSet(NetBoxModelViewSet):
-    queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = FrontPortTemplate.objects.all()
     serializer_class = serializers.FrontPortTemplateSerializer
     filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 class RearPortTemplateViewSet(NetBoxModelViewSet):
-    queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = RearPortTemplate.objects.all()
     serializer_class = serializers.RearPortTemplateSerializer
     filterset_class = filtersets.RearPortTemplateFilterSet
 
 
 class ModuleBayTemplateViewSet(NetBoxModelViewSet):
-    queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = ModuleBayTemplate.objects.all()
     serializer_class = serializers.ModuleBayTemplateSerializer
     filterset_class = filtersets.ModuleBayTemplateFilterSet
 
 
 class DeviceBayTemplateViewSet(NetBoxModelViewSet):
-    queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
+    queryset = DeviceBayTemplate.objects.all()
     serializer_class = serializers.DeviceBayTemplateSerializer
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
-    queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
+    queryset = InventoryItemTemplate.objects.all()
     serializer_class = serializers.InventoryItemTemplateSerializer
     filterset_class = filtersets.InventoryItemTemplateFilterSet
 
@@ -359,7 +351,7 @@ class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class DeviceRoleViewSet(NetBoxModelViewSet):
-    queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
+    queryset = DeviceRole.objects.annotate(
         device_count=count_related(Device, 'role'),
         virtualmachine_count=count_related(VirtualMachine, 'role')
     )
@@ -372,7 +364,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
 #
 
 class PlatformViewSet(NetBoxModelViewSet):
-    queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate(
+    queryset = Platform.objects.annotate(
         device_count=count_related(Device, 'platform'),
         virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
@@ -391,8 +383,7 @@ class DeviceViewSet(
     NetBoxModelViewSet
 ):
     queryset = Device.objects.prefetch_related(
-        'device_type__manufacturer', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
-        'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
+        'parent_bay',  # Referenced by DeviceSerializer.get_parent_device()
     )
     filterset_class = filtersets.DeviceFilterSet
     pagination_class = StripCountAnnotationsPaginator
@@ -419,9 +410,7 @@ class DeviceViewSet(
 
 
 class VirtualDeviceContextViewSet(NetBoxModelViewSet):
-    queryset = VirtualDeviceContext.objects.prefetch_related(
-        'device__device_type', 'device', 'tenant', 'tags',
-    ).annotate(
+    queryset = VirtualDeviceContext.objects.annotate(
         interface_count=count_related(Interface, 'vdcs'),
     )
     serializer_class = serializers.VirtualDeviceContextSerializer
@@ -429,9 +418,7 @@ class VirtualDeviceContextViewSet(NetBoxModelViewSet):
 
 
 class ModuleViewSet(NetBoxModelViewSet):
-    queryset = Module.objects.prefetch_related(
-        'device', 'module_bay', 'module_type__manufacturer', 'tags',
-    )
+    queryset = Module.objects.all()
     serializer_class = serializers.ModuleSerializer
     filterset_class = filtersets.ModuleFilterSet
 
@@ -442,49 +429,45 @@ class ModuleViewSet(NetBoxModelViewSet):
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsolePort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
+        '_path', 'cable__terminations',
     )
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
+        '_path', 'cable__terminations',
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
+        '_path', 'cable__terminations',
     )
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
+        '_path', 'cable__terminations',
     )
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
-        'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
-        'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
-        'vdcs',
+        '_path', 'cable__terminations',
+        'l2vpn_terminations',  # Referenced by InterfaceSerializer.l2vpn_termination
+        'ip_addresses',  # Referenced by Interface.count_ipaddresses()
+        'fhrp_group_assignments',  # Referenced by Interface.count_fhrp_groups()
     )
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filtersets.InterfaceFilterSet
-    brief_prefetch_fields = ['device']
 
     def get_bulk_destroy_queryset(self):
         # Ensure child interfaces are deleted prior to their parents
@@ -493,41 +476,36 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = FrontPort.objects.prefetch_related(
-        'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
+        'cable__terminations',
     )
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filtersets.FrontPortFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = RearPort.objects.prefetch_related(
-        'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
+        'cable__terminations',
     )
     serializer_class = serializers.RearPortSerializer
     filterset_class = filtersets.RearPortFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class ModuleBayViewSet(NetBoxModelViewSet):
-    queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
+    queryset = ModuleBay.objects.all()
     serializer_class = serializers.ModuleBaySerializer
     filterset_class = filtersets.ModuleBayFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class DeviceBayViewSet(NetBoxModelViewSet):
-    queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags')
+    queryset = DeviceBay.objects.all()
     serializer_class = serializers.DeviceBaySerializer
     filterset_class = filtersets.DeviceBayFilterSet
-    brief_prefetch_fields = ['device']
 
 
 class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
+    queryset = InventoryItem.objects.all()
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet
-    brief_prefetch_fields = ['device']
 
 
 #
@@ -535,7 +513,7 @@ class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 #
 
 class InventoryItemRoleViewSet(NetBoxModelViewSet):
-    queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
+    queryset = InventoryItemRole.objects.annotate(
         inventoryitem_count=count_related(InventoryItem, 'role')
     )
     serializer_class = serializers.InventoryItemRoleSerializer
@@ -554,7 +532,7 @@ class CableViewSet(NetBoxModelViewSet):
 
 class CableTerminationViewSet(NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
-    queryset = CableTermination.objects.prefetch_related('cable', 'termination')
+    queryset = CableTermination.objects.all()
     serializer_class = serializers.CableTerminationSerializer
     filterset_class = filtersets.CableTerminationFilterSet
 
@@ -564,10 +542,9 @@ class CableTerminationViewSet(NetBoxModelViewSet):
 #
 
 class VirtualChassisViewSet(NetBoxModelViewSet):
-    queryset = VirtualChassis.objects.prefetch_related('tags')
+    queryset = VirtualChassis.objects.all()
     serializer_class = serializers.VirtualChassisSerializer
     filterset_class = filtersets.VirtualChassisFilterSet
-    brief_prefetch_fields = ['master']
 
 
 #
@@ -575,9 +552,7 @@ class VirtualChassisViewSet(NetBoxModelViewSet):
 #
 
 class PowerPanelViewSet(NetBoxModelViewSet):
-    queryset = PowerPanel.objects.prefetch_related(
-        'site', 'location'
-    ).annotate(
+    queryset = PowerPanel.objects.annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     serializer_class = serializers.PowerPanelSerializer
@@ -590,7 +565,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerFeed.objects.prefetch_related(
-        'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
+        '_path', 'cable__terminations',
     )
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet

+ 6 - 0
netbox/dcim/forms/bulk_edit.py

@@ -557,6 +557,9 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
         label=_('Device type'),
         queryset=DeviceType.objects.all(),
         required=False,
+        context={
+            'parent': 'manufacturer',
+        },
         query_params={
             'manufacturer_id': '$manufacturer'
         }
@@ -640,6 +643,9 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
         required=False,
         query_params={
             'manufacturer_id': '$manufacturer'
+        },
+        context={
+            'parent': 'manufacturer',
         }
     )
     status = forms.ChoiceField(

+ 9 - 3
netbox/dcim/forms/connections.py

@@ -30,7 +30,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=term_cls._meta.verbose_name.title(),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'device_id': f'$termination_{cable_end}_device',
                             'kind': 'physical',  # Exclude virtual interfaces
@@ -52,7 +54,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=_('Power Feed'),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'power_panel_id': f'$termination_{cable_end}_powerpanel',
                         }
@@ -72,7 +76,9 @@ def get_cable_form(a_type, b_type):
                     attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
                         queryset=term_cls.objects.all(),
                         label=_('Side'),
-                        disabled_indicator='_occupied',
+                        context={
+                            'disabled': '_occupied',
+                        },
                         query_params={
                             'circuit_id': f'$termination_{cable_end}_circuit',
                         }

+ 22 - 4
netbox/dcim/forms/model_forms.py

@@ -426,7 +426,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         widget=APISelect(
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             attrs={
-                'disabled-indicator': 'device',
+                'ts-disabled-field': 'device',
                 'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
             },
         )
@@ -434,6 +434,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
         queryset=DeviceType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        },
         selector=True
     )
     role = DynamicModelChoiceField(
@@ -461,6 +464,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         label=_('Virtual chassis'),
         queryset=VirtualChassis.objects.all(),
         required=False,
+        context={
+            'parent': 'master',
+        },
         selector=True
     )
     vc_position = forms.IntegerField(
@@ -568,6 +574,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
     module_type = DynamicModelChoiceField(
         label=_('Module type'),
         queryset=ModuleType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        },
         selector=True
     )
     comments = CommentField()
@@ -774,7 +783,10 @@ class VCMemberSelectForm(forms.Form):
 class ComponentTemplateForm(forms.ModelForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
-        queryset=DeviceType.objects.all()
+        queryset=DeviceType.objects.all(),
+        context={
+            'parent': 'manufacturer',
+        }
     )
 
     def __init__(self, *args, **kwargs):
@@ -789,12 +801,18 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
     device_type = DynamicModelChoiceField(
         label=_('Device type'),
         queryset=DeviceType.objects.all().all(),
-        required=False
+        required=False,
+        context={
+            'parent': 'manufacturer',
+        }
     )
     module_type = DynamicModelChoiceField(
         label=_('Module type'),
         queryset=ModuleType.objects.all(),
-        required=False
+        required=False,
+        context={
+            'parent': 'manufacturer',
+        }
     )
 
     def __init__(self, *args, **kwargs):

+ 4 - 7
netbox/extras/api/views.py

@@ -115,7 +115,7 @@ class CustomLinkViewSet(NetBoxModelViewSet):
 
 class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
     metadata_class = ContentTypeMetadata
-    queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file')
+    queryset = ExportTemplate.objects.all()
     serializer_class = serializers.ExportTemplateSerializer
     filterset_class = filtersets.ExportTemplateFilterSet
 
@@ -181,10 +181,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
 #
 
 class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
-    queryset = ConfigContext.objects.prefetch_related(
-        'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
-        'data_file',
-    )
+    queryset = ConfigContext.objects.all()
     serializer_class = serializers.ConfigContextSerializer
     filterset_class = filtersets.ConfigContextFilterSet
 
@@ -194,7 +191,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
 #
 
 class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
-    queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
+    queryset = ConfigTemplate.objects.all()
     serializer_class = serializers.ConfigTemplateSerializer
     filterset_class = filtersets.ConfigTemplateFilterSet
 
@@ -312,7 +309,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     Retrieve a list of recent changes.
     """
     metadata_class = ContentTypeMetadata
-    queryset = ObjectChange.objects.valid_models().prefetch_related('user')
+    queryset = ObjectChange.objects.valid_models()
     serializer_class = serializers.ObjectChangeSerializer
     filterset_class = filtersets.ObjectChangeFilterSet
 

+ 4 - 1
netbox/extras/scripts.py

@@ -193,16 +193,19 @@ class ObjectVar(ScriptVariable):
 
     :param model: The NetBox model being referenced
     :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
+    :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
+        elements within the dropdown menu (optional)
     :param null_option: The label to use as a "null" selection option (optional)
     """
     form_field = DynamicModelChoiceField
 
-    def __init__(self, model, query_params=None, null_option=None, *args, **kwargs):
+    def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         self.field_attrs.update({
             'queryset': model.objects.all(),
             'query_params': query_params,
+            'context': context,
             'null_option': null_option,
         })
 

+ 2 - 1
netbox/ipam/api/nested_serializers.py

@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
 
 class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
+    group = NestedFHRPGroupSerializer()
 
     class Meta:
         model = models.FHRPGroupAssignment
-        fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
+        fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
 
 
 #

+ 16 - 25
netbox/ipam/api/views.py

@@ -39,13 +39,13 @@ class IPAMRootView(APIRootView):
 #
 
 class ASNRangeViewSet(NetBoxModelViewSet):
-    queryset = ASNRange.objects.prefetch_related('tenant', 'rir').all()
+    queryset = ASNRange.objects.all()
     serializer_class = serializers.ASNRangeSerializer
     filterset_class = filtersets.ASNRangeFilterSet
 
 
 class ASNViewSet(NetBoxModelViewSet):
-    queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(
+    queryset = ASN.objects.annotate(
         site_count=count_related(Site, 'asns'),
         provider_count=count_related(Provider, 'asns')
     )
@@ -54,9 +54,7 @@ class ASNViewSet(NetBoxModelViewSet):
 
 
 class VRFViewSet(NetBoxModelViewSet):
-    queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
-        'import_targets', 'export_targets', 'tags'
-    ).annotate(
+    queryset = VRF.objects.annotate(
         ipaddress_count=count_related(IPAddress, 'vrf'),
         prefix_count=count_related(Prefix, 'vrf')
     )
@@ -65,7 +63,7 @@ class VRFViewSet(NetBoxModelViewSet):
 
 
 class RouteTargetViewSet(NetBoxModelViewSet):
-    queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
+    queryset = RouteTarget.objects.all()
     serializer_class = serializers.RouteTargetSerializer
     filterset_class = filtersets.RouteTargetFilterSet
 
@@ -73,13 +71,13 @@ class RouteTargetViewSet(NetBoxModelViewSet):
 class RIRViewSet(NetBoxModelViewSet):
     queryset = RIR.objects.annotate(
         aggregate_count=count_related(Aggregate, 'rir')
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.RIRSerializer
     filterset_class = filtersets.RIRFilterSet
 
 
 class AggregateViewSet(NetBoxModelViewSet):
-    queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
+    queryset = Aggregate.objects.all()
     serializer_class = serializers.AggregateSerializer
     filterset_class = filtersets.AggregateFilterSet
 
@@ -88,15 +86,13 @@ class RoleViewSet(NetBoxModelViewSet):
     queryset = Role.objects.annotate(
         prefix_count=count_related(Prefix, 'role'),
         vlan_count=count_related(VLAN, 'role')
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.RoleSerializer
     filterset_class = filtersets.RoleFilterSet
 
 
 class PrefixViewSet(NetBoxModelViewSet):
-    queryset = Prefix.objects.prefetch_related(
-        'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
-    )
+    queryset = Prefix.objects.all()
     serializer_class = serializers.PrefixSerializer
     filterset_class = filtersets.PrefixFilterSet
 
@@ -109,7 +105,7 @@ class PrefixViewSet(NetBoxModelViewSet):
 
 
 class IPRangeViewSet(NetBoxModelViewSet):
-    queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
+    queryset = IPRange.objects.all()
     serializer_class = serializers.IPRangeSerializer
     filterset_class = filtersets.IPRangeFilterSet
 
@@ -117,9 +113,7 @@ class IPRangeViewSet(NetBoxModelViewSet):
 
 
 class IPAddressViewSet(NetBoxModelViewSet):
-    queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
-    )
+    queryset = IPAddress.objects.all()
     serializer_class = serializers.IPAddressSerializer
     filterset_class = filtersets.IPAddressFilterSet
 
@@ -137,27 +131,26 @@ class IPAddressViewSet(NetBoxModelViewSet):
 
 
 class FHRPGroupViewSet(NetBoxModelViewSet):
-    queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
+    queryset = FHRPGroup.objects.all()
     serializer_class = serializers.FHRPGroupSerializer
     filterset_class = filtersets.FHRPGroupFilterSet
-    brief_prefetch_fields = ('ip_addresses',)
 
 
 class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
-    queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface')
+    queryset = FHRPGroupAssignment.objects.all()
     serializer_class = serializers.FHRPGroupAssignmentSerializer
     filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
 class VLANGroupViewSet(NetBoxModelViewSet):
-    queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
+    queryset = VLANGroup.objects.annotate_utilization()
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filtersets.VLANGroupFilterSet
 
 
 class VLANViewSet(NetBoxModelViewSet):
     queryset = VLAN.objects.prefetch_related(
-        'site', 'group', 'tenant', 'role', 'tags'
+        'l2vpn_terminations',  # Referenced by VLANSerializer.l2vpn_termination
     ).annotate(
         prefix_count=count_related(Prefix, 'vlan')
     )
@@ -166,15 +159,13 @@ class VLANViewSet(NetBoxModelViewSet):
 
 
 class ServiceTemplateViewSet(NetBoxModelViewSet):
-    queryset = ServiceTemplate.objects.prefetch_related('tags')
+    queryset = ServiceTemplate.objects.all()
     serializer_class = serializers.ServiceTemplateSerializer
     filterset_class = filtersets.ServiceTemplateFilterSet
 
 
 class ServiceViewSet(NetBoxModelViewSet):
-    queryset = Service.objects.prefetch_related(
-        'device', 'virtual_machine', 'tags', 'ipaddresses'
-    )
+    queryset = Service.objects.all()
     serializer_class = serializers.ServiceSerializer
     filterset_class = filtersets.ServiceFilterSet
 

+ 7 - 1
netbox/ipam/forms/model_forms.py

@@ -267,14 +267,20 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
 
 class IPAddressForm(TenancyForm, NetBoxModelForm):
     interface = DynamicModelChoiceField(
-        label=_('Interface'),
         queryset=Interface.objects.all(),
         required=False,
+        context={
+            'parent': 'device',
+        },
         selector=True,
+        label=_('Interface'),
     )
     vminterface = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,
+        context={
+            'parent': 'virtual_machine',
+        },
         selector=True,
         label=_('Interface'),
     )

+ 1 - 1
netbox/ipam/tests/test_api.py

@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
 
 class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
     model = FHRPGroupAssignment
-    brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
+    brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
     bulk_update_data = {
         'priority': 100,
     }

+ 9 - 0
netbox/netbox/api/serializers/base.py

@@ -12,6 +12,15 @@ __all__ = (
 class BaseModelSerializer(serializers.ModelSerializer):
     display = serializers.SerializerMethodField(read_only=True)
 
+    def __init__(self, *args, requested_fields=None, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # If specific fields have been requested, omit the others
+        if requested_fields:
+            for field in list(self.fields.keys()):
+                if field not in requested_fields:
+                    self.fields.pop(field)
+
     @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
         return str(obj)

+ 28 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -1,4 +1,5 @@
 import logging
+from functools import cached_property
 
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
@@ -9,6 +10,7 @@ from rest_framework import mixins as drf_mixins
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
+from utilities.api import get_prefetches_for_serializer
 from utilities.exceptions import AbortRequest
 from . import mixins
 
@@ -40,6 +42,32 @@ class BaseViewSet(GenericViewSet):
             if action := HTTP_ACTIONS[request.method]:
                 self.queryset = self.queryset.restrict(request.user, action)
 
+    def get_queryset(self):
+        qs = super().get_queryset()
+
+        # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
+        prefetch = get_prefetches_for_serializer(
+            self.get_serializer_class(),
+            fields_to_include=self.requested_fields
+        )
+        if prefetch:
+            qs = qs.prefetch_related(*prefetch)
+
+        return qs
+
+    def get_serializer(self, *args, **kwargs):
+
+        # If specific fields have been requested, pass them to the serializer
+        if self.requested_fields:
+            kwargs['requested_fields'] = self.requested_fields
+
+        return super().get_serializer(*args, **kwargs)
+
+    @cached_property
+    def requested_fields(self):
+        requested_fields = self.request.query_params.get('fields')
+        return requested_fields.split(',') if requested_fields else []
+
 
 class NetBoxReadOnlyModelViewSet(
     mixins.BriefModeMixin,

+ 0 - 4
netbox/netbox/api/viewsets/mixins.py

@@ -30,7 +30,6 @@ class BriefModeMixin:
         GET /api/dcim/sites/?brief=True
     """
     brief = False
-    brief_prefetch_fields = []
 
     def initialize_request(self, request, *args, **kwargs):
         # Annotate whether brief mode is active
@@ -64,9 +63,6 @@ class BriefModeMixin:
                 if annotation not in serializer_class().fields:
                     qs.query.annotations.pop(annotation)
 
-            # Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
-            return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
-
         return qs
 
 

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


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


+ 43 - 3
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -31,6 +31,15 @@ export class DynamicTomSelect extends TomSelect {
     // Glean the REST API endpoint URL from the <select> element
     this.api_url = this.input.getAttribute('data-url') as string;
 
+    // Override any field names set as widget attributes
+    this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
+    this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField;
+    this.disabledField = this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
+    this.descriptionField = this.input.getAttribute('ts-description-field') || 'description';
+    this.depthField = this.input.getAttribute('ts-depth-field') || '_depth';
+    this.parentField = this.input.getAttribute('ts-parent-field') || null;
+    this.countField = this.input.getAttribute('ts-count-field') || null;
+
     // Set the null option (if any)
     const nullOption = this.input.getAttribute('data-null-option');
     if (nullOption) {
@@ -82,10 +91,20 @@ export class DynamicTomSelect extends TomSelect {
     // Make the API request
     fetch(url)
       .then(response => response.json())
-      .then(json => {
-          self.loadCallback(json.results, []);
+      .then(apiData => {
+        const results: Dict[] = apiData.results;
+        let options: Dict[] = []
+        for (let result of results) {
+          const option = self.getOptionFromData(result);
+          options.push(option);
+        }
+        return options;
+      })
+      // Pass the options to the callback function
+      .then(options => {
+        self.loadCallback(options, []);
       }).catch(()=>{
-          self.loadCallback([], []);
+        self.loadCallback([], []);
       });
 
   }
@@ -126,6 +145,27 @@ export class DynamicTomSelect extends TomSelect {
     return queryString.stringifyUrl({ url, query });
   }
 
+  // Compile TomOption data from an API result
+  getOptionFromData(data: Dict) {
+    let option: Dict = {
+      id: data[this.valueField],
+      display: data[this.labelField],
+      depth: data[this.depthField] || null,
+      description: data[this.descriptionField] || null,
+    };
+    if (data[this.parentField]) {
+      let parent: Dict = data[this.parentField] as Dict;
+      option['parent'] = parent[this.labelField];
+    }
+    if (data[this.countField]) {
+      option['count'] = data[this.countField];
+    }
+    if (data[this.disabledField]) {
+      option['disabled'] = data[this.disabledField];
+    }
+    return option
+  }
+
   /**
    * Transitional methods
    */

+ 27 - 8
netbox/project-static/src/select/dynamic.ts

@@ -10,12 +10,34 @@ const MAX_OPTIONS = 100;
 
 // Render the HTML for a dropdown option
 function renderOption(data: TomOption, escape: typeof escape_html) {
-  // If the option has a `_depth` property, indent its label
-  if (typeof data._depth === 'number' && data._depth > 0) {
-    return `<div>${'─'.repeat(data._depth)} ${escape(data[LABEL_FIELD])}</div>`;
+  let html = '<div>';
+
+  // If the option has a `depth` property, indent its label
+  if (typeof data.depth === 'number' && data.depth > 0) {
+    html = `${html}${'─'.repeat(data.depth)} `;
+  }
+
+  html = `${html}${escape(data[LABEL_FIELD])}`;
+  if (data['parent']) {
+    html = `${html} <span class="text-secondary">${escape(data['parent'])}</span>`;
+  }
+  if (data['count']) {
+    html = `${html} <span class="badge">${escape(data['count'])}</span>`;
+  }
+  if (data['description']) {
+    html = `${html}<br /><small class="text-secondary">${escape(data['description'])}</small>`;
   }
+  html = `${html}</div>`;
 
-  return `<div>${escape(data[LABEL_FIELD])}</div>`;
+  return html;
+}
+
+// Render the HTML for a selected item
+function renderItem(data: TomOption, escape: typeof escape_html) {
+  if (data['parent']) {
+    return `<div>${escape(data['parent'])} > ${escape(data[LABEL_FIELD])}</div>`;
+  }
+  return `<div>${escape(data[LABEL_FIELD])}<div>`;
 }
 
 // Initialize <select> elements which are populated via a REST API call
@@ -30,16 +52,13 @@ export function initDynamicSelects(): void {
       // Disable local search (search is performed on the backend)
       searchField: [],
 
-      // Reference the disabled-indicator attr on the <select> element to determine
-      // the name of the attribute which indicates whether an option should be disabled
-      disabledField: select.getAttribute('disabled-indicator') || undefined,
-
       // Load options from API immediately on focus
       preload: 'focus',
 
       // Define custom rendering functions
       render: {
         option: renderOption,
+        item: renderItem,
       },
 
       // By default, load() will be called only if query.length > 0

+ 8 - 3
netbox/project-static/src/select/static.ts

@@ -17,13 +17,18 @@ export function initStaticSelects(): void {
 
 // Initialize color selection fields
 export function initColorSelects(): void {
+  function renderColor(item: TomOption, escape: typeof escape_html) {
+    return `<div><span class="dropdown-item-indicator color-label" style="background-color: #${escape(
+      item.value,
+    )}"></span> ${escape(item.text)}</div>`;
+  }
+
   for (const select of getElements<HTMLSelectElement>('select.color-select')) {
     new TomSelect(select, {
       ...config,
       render: {
-        option: function (item: TomOption, escape: typeof escape_html) {
-          return `<div style="background-color: #${escape(item.value)}">${escape(item.text)}</div>`;
-        },
+        option: renderColor,
+        item: renderColor,
       },
     });
   }

+ 6 - 8
netbox/tenancy/api/views.py

@@ -30,15 +30,13 @@ class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
         'group',
         'tenant_count',
         cumulative=True
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.TenantGroupSerializer
     filterset_class = filtersets.TenantGroupFilterSet
 
 
 class TenantViewSet(NetBoxModelViewSet):
-    queryset = Tenant.objects.prefetch_related(
-        'group', 'tags'
-    ).annotate(
+    queryset = Tenant.objects.annotate(
         circuit_count=count_related(Circuit, 'tenant'),
         device_count=count_related(Device, 'tenant'),
         ipaddress_count=count_related(IPAddress, 'tenant'),
@@ -65,24 +63,24 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
         'group',
         'contact_count',
         cumulative=True
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.ContactGroupSerializer
     filterset_class = filtersets.ContactGroupFilterSet
 
 
 class ContactRoleViewSet(NetBoxModelViewSet):
-    queryset = ContactRole.objects.prefetch_related('tags')
+    queryset = ContactRole.objects.all()
     serializer_class = serializers.ContactRoleSerializer
     filterset_class = filtersets.ContactRoleFilterSet
 
 
 class ContactViewSet(NetBoxModelViewSet):
-    queryset = Contact.objects.prefetch_related('group', 'tags')
+    queryset = Contact.objects.all()
     serializer_class = serializers.ContactSerializer
     filterset_class = filtersets.ContactFilterSet
 
 
 class ContactAssignmentViewSet(NetBoxModelViewSet):
-    queryset = ContactAssignment.objects.prefetch_related('content_type', 'object', 'contact', 'role', 'tags')
+    queryset = ContactAssignment.objects.all()
     serializer_class = serializers.ContactAssignmentSerializer
     filterset_class = filtersets.ContactAssignmentFilterSet

+ 3 - 3
netbox/users/api/views.py

@@ -34,7 +34,7 @@ class UsersRootView(APIRootView):
 #
 
 class UserViewSet(NetBoxModelViewSet):
-    queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username')
+    queryset = RestrictedQuerySet(model=get_user_model()).order_by('username')
     serializer_class = serializers.UserSerializer
     filterset_class = filtersets.UserFilterSet
 
@@ -50,7 +50,7 @@ class GroupViewSet(NetBoxModelViewSet):
 #
 
 class TokenViewSet(NetBoxModelViewSet):
-    queryset = Token.objects.prefetch_related('user')
+    queryset = Token.objects.all()
     serializer_class = serializers.TokenSerializer
     filterset_class = filtersets.TokenFilterSet
 
@@ -86,7 +86,7 @@ class TokenProvisionView(APIView):
 #
 
 class ObjectPermissionViewSet(NetBoxModelViewSet):
-    queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
+    queryset = ObjectPermission.objects.all()
     serializer_class = serializers.ObjectPermissionSerializer
     filterset_class = filtersets.ObjectPermissionFilterSet
 

+ 42 - 0
netbox/utilities/api.py

@@ -2,9 +2,13 @@ import platform
 import sys
 
 from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models.fields.related import ManyToOneRel, RelatedField
 from django.http import JsonResponse
 from django.urls import reverse
 from rest_framework import status
+from rest_framework.serializers import Serializer
 from rest_framework.utils import formatting
 
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
@@ -12,6 +16,7 @@ from .utils import dynamic_import
 
 __all__ = (
     'get_graphql_type_for_model',
+    'get_prefetches_for_serializer',
     'get_serializer_for_model',
     'get_view_name',
     'is_api_request',
@@ -89,6 +94,43 @@ def get_view_name(view, suffix=None):
     return name
 
 
+def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
+    """
+    Compile and return a list of fields which should be prefetched on the queryset for a serializer.
+    """
+    model = serializer_class.Meta.model
+
+    # If specific fields are not specified, default to all
+    if not fields_to_include:
+        fields_to_include = serializer_class.Meta.fields
+
+    prefetch_fields = []
+    for field_name in fields_to_include:
+        serializer_field = serializer_class._declared_fields.get(field_name)
+
+        # Determine the name of the model field referenced by the serializer field
+        model_field_name = field_name
+        if serializer_field and serializer_field.source:
+            model_field_name = serializer_field.source
+
+        # If the serializer field does not map to a discrete model field, skip it.
+        try:
+            field = model._meta.get_field(model_field_name)
+            if isinstance(field, (RelatedField, ManyToOneRel, GenericForeignKey)):
+                prefetch_fields.append(field.name)
+        except FieldDoesNotExist:
+            continue
+
+        # If this field is represented by a nested serializer, recurse to resolve prefetches
+        # for the related object.
+        if serializer_field:
+            if issubclass(type(serializer_field), Serializer):
+                for subfield in get_prefetches_for_serializer(type(serializer_field)):
+                    prefetch_fields.append(f'{field_name}__{subfield}')
+
+    return prefetch_fields
+
+
 def rest_api_server_error(request, *args, **kwargs):
     """
     Handle exceptions and return a useful error message for REST API requests.

+ 22 - 4
netbox/utilities/forms/fields/dynamic.py

@@ -63,8 +63,19 @@ class DynamicModelChoiceMixin:
         initial_params: A dictionary of child field references to use for selecting a parent field's initial value
         null_option: The string used to represent a null selection (if any)
         disabled_indicator: The name of the field which, if populated, will disable selection of the
-            choice (optional)
+            choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
+        context: A mapping of <option> template variables to their API data keys (optional; see below)
         selector: Include an advanced object selection widget to assist the user in identifying the desired object
+
+    Context keys:
+        value: The name of the attribute which contains the option's value (default: 'id')
+        label: The name of the attribute used as the option's human-friendly label (default: 'display')
+        description: The name of the attribute to use as a description (default: 'description')
+        depth: The name of the attribute which indicates an object's depth within a recursive hierarchy; must be a
+            positive integer (default: '_depth')
+        disabled: The name of the attribute which, if true, signifies that the option should be disabled
+        parent: The name of the attribute which represents the object's parent object (e.g. device for an interface)
+        count: The name of the attribute which contains a numeric count of related objects
     """
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
@@ -77,6 +88,7 @@ class DynamicModelChoiceMixin:
             initial_params=None,
             null_option=None,
             disabled_indicator=None,
+            context=None,
             selector=False,
             **kwargs
     ):
@@ -85,6 +97,7 @@ class DynamicModelChoiceMixin:
         self.initial_params = initial_params or {}
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
+        self.context = context or {}
         self.selector = selector
 
         super().__init__(queryset, **kwargs)
@@ -96,12 +109,17 @@ class DynamicModelChoiceMixin:
         if self.null_option is not None:
             attrs['data-null-option'] = self.null_option
 
-        # Set the disabled indicator, if any
+        # Set any custom template attributes for TomSelect
+        for var, accessor in self.context.items():
+            attrs[f'ts-{var}-field'] = accessor
+
+        # TODO: Remove in v4.1
+        # Legacy means of specifying the disabled indicator
         if self.disabled_indicator is not None:
-            attrs['disabled-indicator'] = self.disabled_indicator
+            attrs['ts-disabled-field'] = self.disabled_indicator
 
         # Attach any static query parameters
-        if (len(self.query_params) > 0):
+        if len(self.query_params) > 0:
             widget.add_query_params(self.query_params)
 
         # Include object selector?

+ 8 - 16
netbox/virtualization/api/views.py

@@ -25,7 +25,7 @@ class VirtualizationRootView(APIRootView):
 class ClusterTypeViewSet(NetBoxModelViewSet):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filtersets.ClusterTypeFilterSet
 
@@ -33,15 +33,13 @@ class ClusterTypeViewSet(NetBoxModelViewSet):
 class ClusterGroupViewSet(NetBoxModelViewSet):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
-    ).prefetch_related('tags')
+    )
     serializer_class = serializers.ClusterGroupSerializer
     filterset_class = filtersets.ClusterGroupFilterSet
 
 
 class ClusterViewSet(NetBoxModelViewSet):
-    queryset = Cluster.objects.prefetch_related(
-        'type', 'group', 'tenant', 'site', 'tags'
-    ).annotate(
+    queryset = Cluster.objects.annotate(
         device_count=count_related(Device, 'cluster'),
         virtualmachine_count=count_related(VirtualMachine, 'cluster')
     )
@@ -54,10 +52,7 @@ class ClusterViewSet(NetBoxModelViewSet):
 #
 
 class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
-    queryset = VirtualMachine.objects.prefetch_related(
-        'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
-        'tags', 'virtualdisks',
-    )
+    queryset = VirtualMachine.objects.all()
     filterset_class = filtersets.VirtualMachineFilterSet
 
     def get_serializer_class(self):
@@ -83,12 +78,12 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBo
 
 class VMInterfaceViewSet(NetBoxModelViewSet):
     queryset = VMInterface.objects.prefetch_related(
-        'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses',
-        'fhrp_group_assignments',
+        'l2vpn_terminations',  # Referenced by VMInterfaceSerializer.l2vpn_termination
+        'ip_addresses',  # Referenced by VMInterface.count_ipaddresses()
+        'fhrp_group_assignments',  # Referenced by VMInterface.count_fhrp_groups()
     )
     serializer_class = serializers.VMInterfaceSerializer
     filterset_class = filtersets.VMInterfaceFilterSet
-    brief_prefetch_fields = ['virtual_machine']
 
     def get_bulk_destroy_queryset(self):
         # Ensure child interfaces are deleted prior to their parents
@@ -96,9 +91,6 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
 
 
 class VirtualDiskViewSet(NetBoxModelViewSet):
-    queryset = VirtualDisk.objects.prefetch_related(
-        'virtual_machine', 'tags',
-    )
+    queryset = VirtualDisk.objects.all()
     serializer_class = serializers.VirtualDiskSerializer
     filterset_class = filtersets.VirtualDiskFilterSet
-    brief_prefetch_fields = ['virtual_machine']

+ 4 - 4
netbox/vpn/api/views.py

@@ -42,7 +42,7 @@ class TunnelGroupViewSet(NetBoxModelViewSet):
 
 
 class TunnelViewSet(NetBoxModelViewSet):
-    queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate(
+    queryset = Tunnel.objects.annotate(
         terminations_count=count_related(TunnelTermination, 'tunnel')
     )
     serializer_class = serializers.TunnelSerializer
@@ -50,7 +50,7 @@ class TunnelViewSet(NetBoxModelViewSet):
 
 
 class TunnelTerminationViewSet(NetBoxModelViewSet):
-    queryset = TunnelTermination.objects.prefetch_related('tunnel')
+    queryset = TunnelTermination.objects.all()
     serializer_class = serializers.TunnelTerminationSerializer
     filterset_class = filtersets.TunnelTerminationFilterSet
 
@@ -86,12 +86,12 @@ class IPSecProfileViewSet(NetBoxModelViewSet):
 
 
 class L2VPNViewSet(NetBoxModelViewSet):
-    queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
+    queryset = L2VPN.objects.all()
     serializer_class = serializers.L2VPNSerializer
     filterset_class = filtersets.L2VPNFilterSet
 
 
 class L2VPNTerminationViewSet(NetBoxModelViewSet):
-    queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
+    queryset = L2VPNTermination.objects.all()
     serializer_class = serializers.L2VPNTerminationSerializer
     filterset_class = filtersets.L2VPNTerminationFilterSet

+ 2 - 2
netbox/wireless/api/views.py

@@ -27,12 +27,12 @@ class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
 
 
 class WirelessLANViewSet(NetBoxModelViewSet):
-    queryset = WirelessLAN.objects.prefetch_related('vlan', 'tenant', 'tags')
+    queryset = WirelessLAN.objects.all()
     serializer_class = serializers.WirelessLANSerializer
     filterset_class = filtersets.WirelessLANFilterSet
 
 
 class WirelessLinkViewSet(NetBoxModelViewSet):
-    queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tenant', 'tags')
+    queryset = WirelessLink.objects.all()
     serializer_class = serializers.WirelessLinkSerializer
     filterset_class = filtersets.WirelessLinkFilterSet

+ 6 - 2
netbox/wireless/forms/model_forms.py

@@ -108,7 +108,9 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
             'kind': 'wireless',
             'device_id': '$device_a',
         },
-        disabled_indicator='_occupied',
+        context={
+            'disabled': '_occupied',
+        },
         label=_('Interface')
     )
     site_b = DynamicModelChoiceField(
@@ -148,7 +150,9 @@ class WirelessLinkForm(TenancyForm, NetBoxModelForm):
             'kind': 'wireless',
             'device_id': '$device_b',
         },
-        disabled_indicator='_occupied',
+        context={
+            'disabled': '_occupied',
+        },
         label=_('Interface')
     )
     comments = CommentField()

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