1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

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
This commit is contained in:
Jeremy Stretch
2023-03-13 12:44:26 -04:00
committed by GitHub
parent 2a9178af12
commit d1f76bec37
24 changed files with 276 additions and 601 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -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<HTMLAnchorElement>('#selector_results a')) {
element.addEventListener('click', () => handleSelection(element));
}
}

View File

@ -27,12 +27,9 @@
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane{% if not providernetwork_tab_active %} active{% endif %}" id="site">
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
</div>
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
{% render_field form.provider_network_provider %}
{% render_field form.provider_network %}
</div>
</div>

View File

@ -18,7 +18,6 @@
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.airflow %}
{% render_field form.serial %}
@ -29,8 +28,6 @@
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
</div>
{% 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 @@
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
</div>
{% render_field form.cluster_group %}
{% render_field form.cluster %}
</div>

View File

@ -6,8 +6,6 @@
<div class="row mb-2">
<h5 class="offset-sm-3">Rack</h5>
</div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
{% render_field form.location %}
{% render_field form.name %}

View File

@ -74,3 +74,7 @@ Context:
</div>
{% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' with size='lg' %}
{% endblock %}

View File

@ -0,0 +1,32 @@
{% load form_helpers %}
<div class="modal-header">
<h5 class="modal-title">Select {{ model|meta:"verbose_name"|bettertitle }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body row">
<div class="col-3">
<div class="list-group list-group-flush">
{% for field in form.visible_fields %}
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
{{ field.label }}
</a>
{% endfor %}
</div>
</div>
<div class="col-9">
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
<input type="hidden" name="_search" value="true" />
<div class="tab-content p-1">
{% for field in form.visible_fields %}
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
{% endfor %}
</div>
<div class="text-end">
<button type="submit" class="btn btn-sm btn-primary">Search</button>
</div>
</form>
<div id="selector_results" class="mt-3" data-selector-target="{{ target_id }}"></div>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="list-group">
{% for object in results %}
<a href="#" class="list-group-item list-group-item-action" data-label="{{ object }}" data-value="{{ object.pk }}" data-bs-dismiss="modal">
<h6 class="mb-1">
{{ object }}
{% if object.status %}{% badge object.get_status_display bg_color=object.get_status_color %}{% endif %}
</h6>
{% if object.description %}
<small>{{ object.description }}</small>
{% endif %}
</a>
{% endfor %}
</div>

View File

@ -1,5 +1,5 @@
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog{% if size %} modal-{{ size }}{% endif %}">
<div class="modal-content" id="htmx-modal-content">
{# Dynamic content goes here #}
</div>

View File

@ -121,14 +121,9 @@
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel">
{% render_field form.nat_region %}
{% render_field form.nat_site_group %}
{% render_field form.nat_site %}
{% render_field form.nat_rack %}
{% render_field form.nat_device %}
</div>
<div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
{% render_field form.nat_cluster %}
{% render_field form.nat_virtual_machine %}
</div>
<div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel">

View File

@ -32,15 +32,12 @@
<div class="row mb-3">
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
{% render_field form.device_vlan %}
{% render_field form.vlan %}
</div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.device %}
{% render_field form.interface %}
</div>
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
{% render_field form.virtual_machine %}
{% render_field form.vminterface %}
</div>
</div>

View File

@ -43,12 +43,9 @@
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane{% if not site_tab_active %} active{% endif %}" id="group">
{% render_field form.scope_type %}
{% render_field form.group %}
</div>
<div class="tab-pane{% if site_tab_active %} active{% endif %}" id="site">
{% render_field form.region %}
{% render_field form.sitegroup %}
{% render_field form.site %}
</div>
</div>

View File

@ -26,24 +26,38 @@ class DynamicModelChoiceMixin:
choice (optional)
fetch_trigger: The event type which will cause the select element to
fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
selector: Include an advanced object selection widget to assist the user in identifying the desired object
"""
filter = django_filters.ModelChoiceFilter
widget = widgets.APISelect
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
fetch_trigger=None, empty_label=None, *args, **kwargs):
def __init__(
self,
queryset,
*,
query_params=None,
initial_params=None,
null_option=None,
disabled_indicator=None,
fetch_trigger=None,
empty_label=None,
selector=False,
**kwargs
):
self.model = queryset.model
self.query_params = query_params or {}
self.initial_params = initial_params or {}
self.null_option = null_option
self.disabled_indicator = disabled_indicator
self.fetch_trigger = fetch_trigger
self.selector = selector
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
# by widget_attrs()
self.to_field_name = kwargs.get('to_field_name')
self.empty_option = empty_label or ""
super().__init__(*args, **kwargs)
super().__init__(queryset, **kwargs)
def widget_attrs(self, widget):
attrs = {
@ -70,6 +84,10 @@ class DynamicModelChoiceMixin:
if (len(self.query_params) > 0):
widget.add_query_params(self.query_params)
# Include object selector?
if self.selector:
attrs['selector'] = self.model._meta.label_lower
return attrs
def get_bound_field(self, form, field_name):

View File

@ -121,6 +121,7 @@ class APISelect(forms.Select):
:param api_url: API endpoint URL. Required if not set automatically by the parent field.
"""
template_name = 'widgets/apiselect.html'
option_template_name = 'widgets/select_option.html'
dynamic_params: Dict[str, str]
static_params: Dict[str, List[str]]

View File

@ -0,0 +1,18 @@
{% if widget.attrs.selector %}
<div class="d-flex">
{% include 'django/forms/widgets/select.html' %}
<button
type="button"
title="Open selector"
class="btn btn-sm btn-outline-dark border-input ms-1"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
hx-get="{% url 'htmx_object_selector' %}?_model={{ widget.attrs.selector }}&target={{ widget.attrs.id }}"
hx-target="#htmx-modal-content"
>
<i class="mdi mdi-database-search-outline"></i>
</button>
</div>
{% else %}
{% include 'django/forms/widgets/select.html' %}
{% endif %}

View File

@ -65,41 +65,22 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
queryset=ClusterGroup.objects.all(),
required=False
)
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',
}
selector=True
)
comments = CommentField()
fieldsets = (
('Cluster', ('name', 'type', 'group', 'status', 'description', 'tags')),
('Site', ('region', 'site_group', 'site')),
('Cluster', ('name', 'type', 'group', 'site', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Cluster
fields = (
'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'description', 'comments',
'tags',
'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', 'tags',
)
@ -178,20 +159,12 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
queryset=Site.objects.all(),
required=False
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
null_option='None',
initial_params={
'clusters': '$cluster'
}
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
selector=True,
query_params={
'site_id': '$site',
'group_id': '$cluster_group',
}
)
device = DynamicModelChoiceField(
@ -222,7 +195,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'description', 'tags')),
('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')),
('Site/Cluster', ('site', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@ -232,9 +205,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags',
'local_context_data',
'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data',
]
def __init__(self, *args, **kwargs):
@ -280,7 +252,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all()
queryset=VirtualMachine.objects.all(),
selector=True
)
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),

View File

@ -38,55 +38,16 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
queryset=WirelessLANGroup.objects.all(),
required=False
)
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'
}
)
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('VLAN'),
query_params={
'site_id': '$site',
'group_id': '$vlan_group',
}
selector=True,
label=_('VLAN')
)
comments = CommentField()
fieldsets = (
('Wireless LAN', ('ssid', 'group', 'status', 'description', 'tags')),
('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
('Wireless LAN', ('ssid', 'group', 'vlan', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
)
@ -94,8 +55,8 @@ class WirelessLANForm(TenancyForm, NetBoxModelForm):
class Meta:
model = WirelessLAN
fields = [
'ssid', 'group', 'region', 'site_group', 'site', 'status', 'vlan_group', 'vlan', 'tenant_group', 'tenant',
'auth_type', 'auth_cipher', 'auth_psk', 'description', 'comments', 'tags',
'ssid', 'group', 'status', 'vlan', 'tenant_group', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk',
'description', 'comments', 'tags',
]