Merge branch 'feature' into 9856-strawberry-2

This commit is contained in:
Arthur 2024-02-14 10:43:21 -08:00
commit cc5703c9dd
31 changed files with 357 additions and 186 deletions

View File

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

View File

@ -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`)

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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',
}

View File

@ -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):

View File

@ -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

View File

@ -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,
})

View File

@ -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']
#

View File

@ -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

View File

@ -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'),
)

View File

@ -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,
}

View File

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

View File

@ -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,

View File

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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
*/

View File

@ -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)} `;
}
return `<div>${escape(data[LABEL_FIELD])}</div>`;
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 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

View File

@ -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,
},
});
}

View File

@ -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

View File

@ -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

View File

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

View File

@ -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?

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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()