From d1f76bec376c42e4ab55c1ebf197b40698fb9e3c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Mar 2023 12:44:26 -0400 Subject: [PATCH] Closes #10054: Implement advanced UI controls for object selection (#11952) * WIP * WIP * WIP * Make object selector functional * Replace extraneous form fields with selector widgets * Avoid overlap with filterset field names * Show checkmarks next to visibile filters * Update results automatically when searching * Include selector for device/VM component parent fields * Use selector for filtering VLAN group/site * Limit selector to 100 results --- netbox/circuits/forms/model_forms.py | 40 +-- netbox/dcim/forms/model_forms.py | 338 +++--------------- netbox/ipam/forms/model_forms.py | 173 +-------- netbox/netbox/urls.py | 5 +- netbox/netbox/views/htmx.py | 56 +++ netbox/project-static/dist/netbox.js | Bin 437298 -> 437709 bytes netbox/project-static/dist/netbox.js.map | Bin 401084 -> 401443 bytes netbox/project-static/src/htmx.ts | 3 +- netbox/project-static/src/objectSelector.ts | 32 ++ .../circuits/circuittermination_edit.html | 3 - netbox/templates/dcim/device_edit.html | 4 - netbox/templates/dcim/rack_edit.html | 2 - netbox/templates/generic/object_edit.html | 4 + netbox/templates/htmx/object_selector.html | 32 ++ .../htmx/object_selector_results.html | 13 + netbox/templates/inc/htmx_modal.html | 2 +- netbox/templates/ipam/ipaddress_edit.html | 5 - .../templates/ipam/l2vpntermination_edit.html | 3 - netbox/templates/ipam/vlan_edit.html | 3 - netbox/utilities/forms/fields/dynamic.py | 24 +- netbox/utilities/forms/widgets.py | 1 + .../templates/widgets/apiselect.html | 18 + netbox/virtualization/forms/model_forms.py | 45 +-- netbox/wireless/forms/model_forms.py | 49 +-- 24 files changed, 265 insertions(+), 590 deletions(-) create mode 100644 netbox/netbox/views/htmx.py create mode 100644 netbox/project-static/src/objectSelector.ts create mode 100644 netbox/templates/htmx/object_selector.html create mode 100644 netbox/templates/htmx/object_selector_results.html create mode 100644 netbox/utilities/templates/widgets/apiselect.html diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index d06f0bd9d..537db50df 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext as _ from circuits.models import * -from dcim.models import Region, Site, SiteGroup +from dcim.models import Site from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -114,50 +114,22 @@ class CircuitTerminationForm(NetBoxModelForm): 'provider_id': '$provider', }, ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - }, - required=False - ) - provider_network_provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), required=False, - label='Provider', - initial_params={ - 'networks': 'provider_network' - } + selector=True ) provider_network = DynamicModelChoiceField( queryset=ProviderNetwork.objects.all(), - query_params={ - 'provider_id': '$provider_network_provider', - }, - required=False + required=False, + selector=True ) class Meta: model = CircuitTermination fields = [ - 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider', - 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'tags', + 'provider', 'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', + 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] widgets = { 'port_speed': SelectSpeedWidget(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index f1f392c99..13c58d02f 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -14,9 +14,9 @@ from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, - SlugField, SelectSpeedWidget, + SlugField, SelectSpeedWidget ) -from virtualization.models import Cluster, ClusterGroup +from virtualization.models import Cluster from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -157,26 +157,9 @@ class SiteForm(TenancyForm, NetBoxModelForm): class LocationForm(TenancyForm, NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + selector=True ) parent = DynamicModelChoiceField( queryset=Location.objects.all(), @@ -188,17 +171,14 @@ class LocationForm(TenancyForm, NetBoxModelForm): slug = SlugField() fieldsets = ( - ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', - )), + ('Location', ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', - 'tags', + 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags', ) @@ -219,26 +199,9 @@ class RackRoleForm(NetBoxModelForm): class RackForm(TenancyForm, NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + selector=True ) location = DynamicModelChoiceField( queryset=Location.objects.all(), @@ -256,48 +219,16 @@ class RackForm(TenancyForm, NetBoxModelForm): class Meta: model = Rack fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', - 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', + 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ] class RackReservationForm(TenancyForm, NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - } - ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } + selector=True ) units = NumericArrayField( base_field=forms.IntegerField(), @@ -311,15 +242,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Reservation', ('rack', 'units', 'user', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = RackReservation fields = [ - 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', - 'description', 'comments', 'tags', + 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] @@ -441,26 +371,9 @@ class PlatformForm(NetBoxModelForm): class DeviceForm(TenancyForm, NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + selector=True ) location = DynamicModelChoiceField( queryset=Location.objects.all(), @@ -491,43 +404,21 @@ class DeviceForm(TenancyForm, NetBoxModelForm): } ) ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': '$device_type' - } - ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } + selector=True ) device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all() ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - query_params={ - 'manufacturer_id': ['$manufacturer', 'null'] - } - ) - cluster_group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - null_option='None', - initial_params={ - 'clusters': '$cluster' - } + required=False ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, - query_params={ - 'group_id': '$cluster_group' - } + selector=True ) comments = CommentField() local_context_data = JSONField( @@ -536,7 +427,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) virtual_chassis = DynamicModelChoiceField( queryset=VirtualChassis.objects.all(), - required=False + required=False, + selector=True ) vc_position = forms.IntegerField( required=False, @@ -556,10 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm): class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', - 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', - 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', - 'description', 'config_template', 'comments', 'tags', 'local_context_data' + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', + 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', + 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', + 'local_context_data' ] def __init__(self, *args, **kwargs): @@ -632,18 +524,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): 'device_id': '$device' } ) - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'module_types': '$module_type' - } - ) module_type = DynamicModelChoiceField( queryset=ModuleType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } + selector=True ) comments = CommentField() replicate_components = forms.BooleanField( @@ -651,7 +534,6 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): initial=True, help_text=_("Automatically populate components associated with this module type") ) - adopt_components = forms.BooleanField( required=False, initial=False, @@ -659,9 +541,7 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): ) fieldsets = ( - ('Module', ( - 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags', - )), + ('Module', ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')), ('Hardware', ( 'serial', 'asset_tag', 'replicate_components', 'adopt_components', )), @@ -670,8 +550,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm): class Meta: model = Module fields = [ - 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'adopt_components', 'description', 'comments', + 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'tags', 'replicate_components', + 'adopt_components', 'description', 'comments', ] def __init__(self, *args, **kwargs): @@ -702,26 +582,9 @@ class CableForm(TenancyForm, NetBoxModelForm): class PowerPanelForm(NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + selector=True ) location = DynamicModelChoiceField( queryset=Location.objects.all(), @@ -733,80 +596,38 @@ class PowerPanelForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')), + ('Power Panel', ('site', 'location', 'name', 'description', 'tags')), ) class Meta: model = PowerPanel fields = [ - 'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags', + 'site', 'location', 'name', 'description', 'comments', 'tags', ] class PowerFeedForm(NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites__powerpanel': '$power_panel' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - initial_params={ - 'powerpanel': '$power_panel' - }, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - query_params={ - 'site_id': '$site' - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - }, - initial_params={ - 'racks': '$rack' - } + selector=True ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - query_params={ - 'location_id': '$location', - 'site_id': '$site' - } + selector=True ) comments = CommentField() fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), + ('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) class Meta: model = PowerFeed fields = [ - 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', - 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', - 'tags', + 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'description', 'comments', 'tags', ] @@ -878,43 +699,12 @@ class DeviceVCMembershipForm(forms.ModelForm): class VCMemberSelectForm(BootstrapMixin, forms.Form): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site' - } - ) device = DynamicModelChoiceField( queryset=Device.objects.all(), query_params={ - 'site_id': '$site', - 'rack_id': '$rack', 'virtual_chassis_id': 'null', - } + }, + selector=True ) def clean_device(self): @@ -1150,7 +940,8 @@ class InventoryItemTemplateForm(ComponentTemplateForm): class DeviceComponentForm(NetBoxModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all() + queryset=Device.objects.all(), + selector=True ) def __init__(self, *args, **kwargs): @@ -1592,53 +1383,9 @@ class InventoryItemRoleForm(NetBoxModelForm): class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - location = DynamicModelChoiceField( - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site' - }, - initial_params={ - 'racks': '$rack' - } - ) - rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) device = DynamicModelChoiceField( queryset=Device.objects.all(), - query_params={ - 'site_id': '$site', - 'location_id': '$location', - 'rack_id': '$rack', - } + selector=True ) primary_ip4 = DynamicModelChoiceField( queryset=IPAddress.objects.all(), @@ -1660,14 +1407,13 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): ) fieldsets = ( - ('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), - ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), + ('Virtual Device Context', ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), ('Tenancy', ('tenant_group', 'tenant')) ) class Meta: model = VirtualDeviceContext fields = [ - 'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier', - 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags' + 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', + 'comments', 'tags' ] diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index a29aabebd..3904281a8 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -200,40 +200,11 @@ class PrefixForm(TenancyForm, NetBoxModelForm): required=False, label=_('VRF') ) - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - } - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, - null_option='None', - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) - vlan_group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - label=_('VLAN group'), - null_option='None', - query_params={ - 'site': '$site' - }, - initial_params={ - 'vlans': '$vlan' - } + selector=True, + null_option='None' ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -241,7 +212,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm): label=_('VLAN'), query_params={ 'site_id': '$site', - 'group_id': '$vlan_group', } ) role = DynamicModelChoiceField( @@ -252,7 +222,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), - ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), + ('Site/VLAN Assignment', ('site', 'vlan')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -329,65 +299,22 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, label=_('VRF') ) - nat_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region'), - initial_params={ - 'sites': '$nat_site' - } - ) - nat_site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group'), - initial_params={ - 'sites': '$nat_site' - } - ) - nat_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - label=_('Site'), - query_params={ - 'region_id': '$nat_region', - 'group_id': '$nat_site_group', - } - ) - nat_rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - required=False, - label=_('Rack'), - null_option='None', - query_params={ - 'site_id': '$site' - } - ) nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - label=_('Device'), - query_params={ - 'site_id': '$site', - 'rack_id': '$nat_rack', - } - ) - nat_cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False, - label=_('Cluster') + selector=True, + label=_('Device') ) nat_virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, - label=_('Virtual Machine'), - query_params={ - 'cluster_id': '$nat_cluster', - } + selector=True, + label=_('Virtual Machine') ) nat_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, + selector=True, label=_('VRF') ) nat_inside = DynamicModelChoiceField( @@ -409,9 +336,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device', - 'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', - 'comments', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine', + 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -714,58 +640,18 @@ class VLANGroupForm(NetBoxModelForm): class VLANForm(TenancyForm, NetBoxModelForm): - # VLANGroup assignment fields - scope_type = forms.ChoiceField( - choices=( - ('', ''), - ('dcim.region', 'Region'), - ('dcim.sitegroup', 'Site group'), - ('dcim.site', 'Site'), - ('dcim.location', 'Location'), - ('dcim.rack', 'Rack'), - ('virtualization.clustergroup', 'Cluster group'), - ('virtualization.cluster', 'Cluster'), - ), - required=False, - label=_('Group scope') - ) group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, - query_params={ - 'scope_type': '$scope_type', - }, + selector=True, label=_('VLAN Group') ) - - # Site assignment fields - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label=_('Region') - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - initial_params={ - 'sites': '$site' - }, - label=_('Site group') - ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, null_option='None', - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } + selector=True ) - - # Other fields role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False @@ -804,11 +690,13 @@ class ServiceTemplateForm(NetBoxModelForm): class ServiceForm(NetBoxModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), - required=False + required=False, + selector=True ) virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), - required=False + required=False, + selector=True ) ports = NumericArrayField( base_field=forms.IntegerField( @@ -908,43 +796,21 @@ class L2VPNTerminationForm(NetBoxModelForm): label=_('L2VPN'), fetch_trigger='open' ) - device_vlan = DynamicModelChoiceField( - queryset=Device.objects.all(), - label=_("Available on Device"), - required=False, - query_params={} - ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - query_params={ - 'available_on_device': '$device_vlan' - }, + selector=True, label=_('VLAN') ) - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={} - ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - query_params={ - 'device_id': '$device' - } - ) - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - query_params={} + selector=True ) vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine' - }, + selector=True, label=_('Interface') ) @@ -958,7 +824,6 @@ class L2VPNTerminationForm(NetBoxModelForm): if instance: if type(instance.assigned_object) is Interface: - initial['device'] = instance.assigned_object.parent initial['interface'] = instance.assigned_object elif type(instance.assigned_object) is VLAN: initial['vlan'] = instance.assigned_object diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 22c47f7bb..8c859528c 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -10,7 +10,7 @@ from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_a from netbox.api.views import APIRootView, StatusView from netbox.graphql.schema import schema from netbox.graphql.views import GraphQLView -from netbox.views import HomeView, StaticMediaFailureView, SearchView +from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx from users.views import LoginView, LogoutView from .admin import admin_site @@ -51,6 +51,9 @@ _patterns = [ path('virtualization/', include('virtualization.urls')), path('wireless/', include('wireless.urls')), + # HTMX views + path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'), + # API path('api/', APIRootView.as_view(), name='api-root'), path('api/circuits/', include('circuits.api.urls')), diff --git a/netbox/netbox/views/htmx.py b/netbox/netbox/views/htmx.py new file mode 100644 index 000000000..04ddcb06b --- /dev/null +++ b/netbox/netbox/views/htmx.py @@ -0,0 +1,56 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 +from django.shortcuts import render +from django.utils.module_loading import import_string +from django.views.generic import View + + +class ObjectSelectorView(View): + template_name = 'htmx/object_selector.html' + + def get(self, request): + model = self._get_model(request.GET.get('_model', '')) + + form_class = self._get_form_class(model) + form = form_class(request.GET) + + if '_search' in request.GET: + # Return only search results + filterset = self._get_filterset_class(model) + + queryset = model.objects.restrict(request.user) + if filterset: + queryset = filterset(request.GET, queryset, request=request).qs + + return render(request, 'htmx/object_selector_results.html', { + 'results': queryset[:100], + }) + + return render(request, self.template_name, { + 'form': form, + 'model': model, + 'target_id': request.GET.get('target'), + }) + + def _get_model(self, label): + try: + app_label, model_name = label.split('.') + content_type = ContentType.objects.get_by_natural_key(app_label, model_name) + except (ValueError, ObjectDoesNotExist): + raise Http404 + return content_type.model_class() + + def _get_form_class(self, model): + if hasattr(self, 'form_class'): + return self.form_class + app_label = model._meta.app_label + class_name = f'{model.__name__}FilterForm' + return import_string(f'{app_label}.forms.{class_name}') + + def _get_filterset_class(self, model): + if hasattr(self, 'filterset_class'): + return self.filterset_class + app_label = model._meta.app_label + class_name = f'{model.__name__}FilterSet' + return import_string(f'{app_label}.filtersets.{class_name}') diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index a64ac5df76b33cc1f1e56c905a6221392ba7f03d..cbb0d10e38fd8933007e9aa2cdc9a8fc2bd85059 100644 GIT binary patch delta 20386 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 H?GhsZ$`xmd delta 76 zcmZ2{Kw{5ci48)|&3ex5dd`eM%ml>DK+LjT&zV*CCcmG)lTMPx# diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index 5b2c65d80..09d423cbd 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -1,9 +1,10 @@ import { getElements, isTruthy } from './util'; import { initButtons } from './buttons'; import { initSelect } from './select'; +import { initObjectSelector } from './objectSelector'; function initDepedencies(): void { - for (const init of [initButtons, initSelect]) { + for (const init of [initButtons, initSelect, initObjectSelector]) { init(); } } diff --git a/netbox/project-static/src/objectSelector.ts b/netbox/project-static/src/objectSelector.ts new file mode 100644 index 000000000..1a6c2dc4b --- /dev/null +++ b/netbox/project-static/src/objectSelector.ts @@ -0,0 +1,32 @@ +import { getElements } from './util'; + +function handleSelection(link: HTMLAnchorElement): void { + const selector_results = document.getElementById('selector_results'); + if (selector_results == null) { + return + } + const target_id = selector_results.getAttribute('data-selector-target'); + if (target_id == null) { + return + } + const target = document.getElementById(target_id); + if (target == null) { + return + } + + const label = link.getAttribute('data-label'); + const value = link.getAttribute('data-value'); + + //@ts-ignore + target.slim.setData([ + {text: label, value: value} + ]); + +} + + +export function initObjectSelector(): void { + for (const element of getElements('#selector_results a')) { + element.addEventListener('click', () => handleSelection(element)); + } +} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index f171ecc1b..acfb8d0ca 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -27,12 +27,9 @@
- {% render_field form.region %} - {% render_field form.site_group %} {% render_field form.site %}
- {% render_field form.provider_network_provider %} {% render_field form.provider_network %}
diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 07e3bbdc9..d0677ad20 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -18,7 +18,6 @@
Hardware
- {% render_field form.manufacturer %} {% render_field form.device_type %} {% render_field form.airflow %} {% render_field form.serial %} @@ -29,8 +28,6 @@
Location
- {% render_field form.region %} - {% render_field form.site_group %} {% render_field form.site %} {% render_field form.location %} {% render_field form.rack %} @@ -76,7 +73,6 @@
Virtualization
- {% render_field form.cluster_group %} {% render_field form.cluster %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index cd9ed637a..413feff31 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -6,8 +6,6 @@
Rack
- {% render_field form.region %} - {% render_field form.site_group %} {% render_field form.site %} {% render_field form.location %} {% render_field form.name %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 8531ad6df..26ceb7987 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -74,3 +74,7 @@ Context: {% endblock content-wrapper %} + +{% block modals %} + {% include 'inc/htmx_modal.html' with size='lg' %} +{% endblock %} diff --git a/netbox/templates/htmx/object_selector.html b/netbox/templates/htmx/object_selector.html new file mode 100644 index 000000000..f0b6da404 --- /dev/null +++ b/netbox/templates/htmx/object_selector.html @@ -0,0 +1,32 @@ +{% load form_helpers %} + + + diff --git a/netbox/templates/htmx/object_selector_results.html b/netbox/templates/htmx/object_selector_results.html new file mode 100644 index 000000000..67529967e --- /dev/null +++ b/netbox/templates/htmx/object_selector_results.html @@ -0,0 +1,13 @@ + diff --git a/netbox/templates/inc/htmx_modal.html b/netbox/templates/inc/htmx_modal.html index 771f5d595..5361fc5f7 100644 --- a/netbox/templates/inc/htmx_modal.html +++ b/netbox/templates/inc/htmx_modal.html @@ -1,5 +1,5 @@