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

Merge pull request #4987 from netbox-community/4982-apiselect-improvements

Closes #4982: Overhaul the APISelect widget and support ObjectVar filtering
This commit is contained in:
Jeremy Stretch
2020-08-13 09:32:32 -04:00
committed by GitHub
13 changed files with 713 additions and 1110 deletions

View File

@ -163,12 +163,46 @@ In the example above, selecting the choice labeled "North" will submit the value
### ObjectVar
A NetBox object of a particular type, identified by the associated queryset.
A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.
* `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model
* `model` - The model class
* `display_field` - The name of the REST API object field to display in the selection list (default: `'name'`)
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
* `null_option` - A label representing a "null" or empty choice (optional)
!!! warning
Because the available options for this field are populated using the REST API, any filtering or exclusions performed on the specified queryset will not have any effect. While it is possible to influence the manner in which field options are populated using NetBox's `APISelect` widget, please note that this component is not officially supported and is planned to be replaced in a future release.
The `display_field` argument is useful when referencing a model which does not have a `name` field. For example, when displaying a list of device types, you would likely use the `model` field:
```python
device_type = ObjectVar(
model=DeviceType,
display_field='model'
)
```
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:
```python
device = ObjectVar(
model=Device,
query_params={
'status': 'active'
}
)
```
Multiple values can be specified by assigning a list to the dictionary key. It is also possible to reference the value of other fields in the form by prepending a dollar sign (`$`) to the variable's name.
```python
region = ObjectVar(
model=Region
)
site = ObjectVar(
model=Site,
query_params={
'region_id': '$region'
}
)
```
### MultiObjectVar
@ -207,9 +241,8 @@ These variables are presented as a web form to be completed by the user. Once su
from django.utils.text import slugify
from dcim.choices import DeviceStatusChoices, SiteStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.scripts import *
from utilities.forms import APISelect
class NewBranchScript(Script):
@ -225,12 +258,17 @@ class NewBranchScript(Script):
switch_count = IntegerVar(
description="Number of access switches to create"
)
manufacturer = ObjectVar(
model=Manufacturer,
required=False
)
switch_model = ObjectVar(
description="Access switch model",
queryset=DeviceType.objects.all(),
widget=APISelect(
display_field='model'
)
model=DeviceType,
display_field='model',
query_params={
'manufacturer_id': '$manufacturer'
}
)
def run(self, data, commit):

View File

@ -8,9 +8,9 @@ from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField,
CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField,
StaticSelect2, StaticSelect2Multiple, TagFilterField,
add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -106,21 +106,15 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
query_params={
'region': '$region'
}
)
asn = forms.IntegerField(
required=False,
@ -271,18 +265,12 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
type = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
provider = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
@ -292,21 +280,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
query_params={
'region': '$region'
}
)
commit_rate = forms.IntegerField(
required=False,

File diff suppressed because it is too large Load Diff

View File

@ -290,42 +290,27 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
@ -335,26 +320,17 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
@ -413,9 +389,9 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
display_field='username',
widget=APISelectMultiple(
api_url='/api/users/users/',
display_field='username'
)
)
changed_object_type = forms.ModelChoiceField(

View File

@ -170,28 +170,42 @@ class ChoiceVar(ScriptVariable):
class ObjectVar(ScriptVariable):
"""
A single object within NetBox.
:param model: The NetBox model being referenced
:param display_field: The attribute of the returned object to display in the selection list (default: 'name')
:param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
:param null_option: The label to use as a "null" selection option (optional)
"""
form_field = DynamicModelChoiceField
def __init__(self, queryset, *args, **kwargs):
def __init__(self, model=None, queryset=None, display_field='name', query_params=None, null_option=None, *args,
**kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
self.field_attrs['queryset'] = queryset
# Set the form field's queryset. Support backward compatibility for the "queryset" argument for now.
if model is not None:
self.field_attrs['queryset'] = model.objects.all()
elif queryset is not None:
warnings.warn(
f'{self}: Specifying a queryset for ObjectVar is no longer supported. Please use "model" instead.'
)
self.field_attrs['queryset'] = queryset
else:
raise TypeError('ObjectVar must specify a model')
self.field_attrs.update({
'display_field': display_field,
'query_params': query_params,
'null_option': null_option,
})
class MultiObjectVar(ScriptVariable):
class MultiObjectVar(ObjectVar):
"""
Like ObjectVar, but can represent one or more objects.
"""
form_field = DynamicModelMultipleChoiceField
def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
self.field_attrs['queryset'] = queryset
class FileVar(ScriptVariable):
"""

View File

@ -1,5 +1,4 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
from dcim.models import Device, Interface, Rack, Region, Site
@ -10,10 +9,9 @@ from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
CSVModelChoiceField, CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, ReturnURLForm,
SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
@ -217,10 +215,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=RIR.objects.all(),
to_field_name='slug',
required=False,
label='RIR',
widget=APISelectMultiple(
value_field="slug",
)
label='RIR'
)
tag = TagFilterField(model)
@ -255,41 +250,32 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
label='VRF',
display_field='display_name'
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vlan_group': 'site_id',
'vlan': 'site_id',
},
attrs={
'nullable': 'true',
}
)
null_option='None'
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group',
widget=APISelect(
filter_for={
'vlan': 'group_id'
},
attrs={
'nullable': 'true',
}
)
null_option='None',
query_params={
'site_id': '$site'
}
)
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='VLAN',
widget=APISelect(
display_field='display_name'
)
display_field='display_name',
query_params={
'site_id': '$site',
'group_id': '$vlan_group',
}
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -469,9 +455,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelectMultiple(
null_option=True,
)
null_option='Global'
)
status = forms.MultipleChoiceField(
choices=PrefixStatusChoices,
@ -481,31 +465,22 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
role = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
is_pool = forms.NullBooleanField(
required=False,
@ -525,29 +500,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
filter_for={
'interface': 'device_id'
}
)
display_field='display_name'
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False
required=False,
query_params={
'device_id': '$device'
}
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vminterface': 'virtual_machine_id'
}
)
required=False
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Interface'
label='Interface',
query_params={
'virtual_machine_id': '$virtual_machine'
}
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
@ -557,56 +529,42 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
nat_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=APISelect(
filter_for={
'nat_rack': 'site_id',
'nat_device': 'site_id'
}
)
label='Site'
)
nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
widget=APISelect(
display_field='display_name',
filter_for={
'nat_device': 'rack_id'
},
attrs={
'nullable': 'true'
}
)
display_field='display_name',
null_option='None',
query_params={
'site_id': '$site'
}
)
nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
widget=APISelect(
display_field='display_name',
filter_for={
'nat_inside': 'device_id'
}
)
display_field='display_name',
query_params={
'site_id': '$site',
'rack_id': '$nat_rack',
}
)
nat_vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
filter_for={
'nat_inside': 'vrf_id'
}
)
label='VRF'
)
nat_inside = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
required=False,
label='IP Address',
widget=APISelect(
display_field='address'
)
display_field='address',
query_params={
'device_id': '$nat_device',
'vrf_id': '$nat_vrf',
}
)
primary_for_parent = forms.BooleanField(
required=False,
@ -920,9 +878,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelectMultiple(
null_option=True,
)
null_option='Global'
)
status = forms.MultipleChoiceField(
choices=IPAddressStatusChoices,
@ -980,22 +936,16 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region',
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
@ -1007,18 +957,14 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
filter_for={
'group': 'site_id'
},
attrs={
'nullable': 'true',
}
)
null_option='None'
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False
required=False,
query_params={
'site_id': '$site'
}
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -1102,16 +1048,14 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
filter_for={
'group': 'site_id'
}
)
required=False
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False
required=False,
query_params={
'site_id': '$site'
}
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -1147,31 +1091,25 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region',
'group_id': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group',
widget=APISelectMultiple(
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
status = forms.MultipleChoiceField(
choices=VLANStatusChoices,
@ -1182,10 +1120,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
queryset=Role.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
tag = TagFilterField(model)

View File

@ -74,7 +74,7 @@ $(document).ready(function() {
form.submit();
});
// Parse URLs which may contain variable refrences to other field values
// Parse URLs which may contain variable references to other field values
function parseURL(url) {
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
@ -87,7 +87,7 @@ $(document).ready(function() {
rendered_url = rendered_url.replace(match[0], custom_attr);
} else if (filter_field.val()) {
rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') {
} else if (filter_field.attr('data-null-option')) {
rendered_url = rendered_url.replace(match[0], 'null');
}
}
@ -123,7 +123,7 @@ $(document).ready(function() {
// API backed selection
// Includes live search and chained fields
// The `multiple` setting may be controled via a data-* attribute
// The `multiple` setting may be controlled via a data-* attribute
$('.netbox-select2-api').select2({
allowClear: true,
placeholder: "---------",
@ -157,47 +157,23 @@ $(document).ready(function() {
// Allow for controlling the brief setting from within APISelect
parameters.brief = ( $(element).is('[data-full]') ? undefined : true );
// filter-for fields from a chain
var attr_name = "data-filter-for-" + $(element).attr("name");
var form = $(element).closest('form');
var filter_for_elements = form.find("select[" + attr_name + "]");
filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name);
var is_required = $(filter_for_element).attr("required");
var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val();
if (param_name && is_visible) {
if (value) {
parameters[param_name] = value;
} else if (is_required && is_nullable) {
parameters[param_name] = "null";
}
}
});
// Conditional query params
// Attach any extra query parameters
$.each(element.attributes, function(index, attr){
if (attr.name.includes("data-conditional-query-param-")){
var conditional = attr.name.split("data-conditional-query-param-")[1].split("__");
var field = $("#id_" + conditional[0]);
var field_value = conditional[1];
if ($('option:selected', field).attr('api-value') === field_value){
var _val = attr.value.split("=");
parameters[_val[0]] = _val[1];
}
}
});
// Additional query params
$.each(element.attributes, function(index, attr){
if (attr.name.includes("data-additional-query-param-")){
var param_name = attr.name.split("data-additional-query-param-")[1];
if (attr.name.includes("data-query-param-")){
var param_name = attr.name.split("data-query-param-")[1];
$.each($.parseJSON(attr.value), function(index, value) {
// Referencing the value of another form field
if (value.startsWith('$')) {
let ref_field = $('#id_' + value.slice(1));
if (ref_field.val() && ref_field.is(":visible")) {
value = ref_field.val();
} else if (ref_field.attr("required") && ref_field.attr("data-null-option")) {
value = "null";
} else {
return true; // Skip if ref_field has no value
}
}
if (param_name in parameters) {
if (Array.isArray(parameters[param_name])) {
parameters[param_name].push(value);
@ -261,7 +237,7 @@ $(document).ready(function() {
if (element.getAttribute('data-null-option') && data.previous === null) {
results.unshift({
id: 'null',
text: 'None'
text: element.getAttribute('data-null-option')
});
}

View File

@ -8,8 +8,8 @@ from extras.forms import (
)
from extras.models import Tag
from utilities.forms import (
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
SlugField, TagFilterField,
)
from .constants import *
from .models import Secret, SecretRole, UserKey
@ -63,7 +63,8 @@ class SecretRoleCSVForm(CSVModelForm):
class SecretForm(BootstrapMixin, CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
queryset=Device.objects.all(),
display_field='display_name'
)
plaintext = forms.CharField(
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
@ -178,10 +179,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
)
required=False
)
tag = TagFilterField(model)

View File

@ -35,33 +35,3 @@
</div>
</form>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var device_list = $('#id_devices');
var disabled_indicator = device_list.attr('disabled-indicator');
$('#id_search').autocomplete({
source: function(request, response) {
$.ajax({
type: 'GET',
url: netbox_api_path + 'dcim/devices/',
data: 'q=' + request.term,
beforeSend: function() {
device_list.empty();
},
success: function(data) {
response($.map(data.results, function(item) {
var option = $("<option></option>").attr("value", item['id']).text(item['display_name']);
if (disabled_indicator && item[disabled_indicator]) {
option.attr("disabled", "disabled");
}
device_list.append(option);
}));
}
});
}
});
});
</script>
{% endblock %}

View File

@ -5,8 +5,8 @@ from extras.forms import (
)
from extras.models import Tag
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField,
BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SlugField, TagFilterField,
)
from .models import Tenant, TenantGroup
@ -106,10 +106,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
tag = TagFilterField(model)
@ -122,18 +119,14 @@ class TenancyForm(forms.Form):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
'tenant': 'group_id',
},
attrs={
'nullable': 'true',
}
)
null_option='None'
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
required=False,
query_params={
'group_id': '$tenant_group'
}
)
def __init__(self, *args, **kwargs):
@ -153,20 +146,14 @@ class TenancyFilterForm(forms.Form):
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
filter_for={
'tenant': 'group'
}
)
null_option='None'
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None',
query_params={
'group': '$tenant_group'
}
)

View File

@ -11,6 +11,7 @@ from django.db.models import Count
from django.forms import BoundField
from django.urls import reverse
from utilities.api import get_serializer_for_model
from utilities.choices import unpack_grouped_choices
from utilities.validators import EnhancedURLValidator
from . import widgets
@ -244,9 +245,58 @@ class TagFilterField(forms.MultipleChoiceField):
class DynamicModelChoiceMixin:
"""
:param display_field: The name of the attribute of an API response object to display in the selection list
:param query_params: A dictionary of additional key/value pairs to attach to the API request
:param null_option: The string used to represent a null selection (if any)
:param disabled_indicator: The name of the field which, if populated, will disable selection of the
choice (optional)
:param brief_mode: Use the "brief" format (?brief=true) when making API requests (default)
"""
filter = django_filters.ModelChoiceFilter
widget = widgets.APISelect
def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None,
brief_mode=True, *args, **kwargs):
self.display_field = display_field
self.query_params = query_params or {}
self.null_option = null_option
self.disabled_indicator = disabled_indicator
self.brief_mode = brief_mode
# 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')
super().__init__(*args, **kwargs)
def widget_attrs(self, widget):
attrs = {
'display-field': self.display_field,
}
# Set value-field attribute if the field specifies to_field_name
if self.to_field_name:
attrs['value-field'] = self.to_field_name
# Set the string used to represent a null option
if self.null_option is not None:
attrs['data-null-option'] = self.null_option
# Set the disabled indicator, if any
if self.disabled_indicator is not None:
attrs['disabled-indicator'] = self.disabled_indicator
# Toggle brief mode
if not self.brief_mode:
attrs['data-full'] = 'true'
# Attach any static query parameters
for key, value in self.query_params.items():
widget.add_query_param(key, value)
return attrs
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)

View File

@ -79,29 +79,12 @@ class SelectWithDisabled(forms.Select):
class StaticSelect2(SelectWithDisabled):
"""
A static content using the Select2 widget
:param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
name of the filter-for field (child field) and the value is the name of the query param filter.
A static <select> form widget using the Select2 library.
"""
def __init__(self, filter_for=None, *args, **kwargs):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs['class'] = 'netbox-select2-static'
if filter_for:
for key, value in filter_for.items():
self.add_filter_for(key, value)
def add_filter_for(self, name, value):
"""
Add details for an additional query param in the form of a data-filter-for-* attribute.
:param name: The name of the query param
:param value: The value of the query param
"""
self.attrs['data-filter-for-{}'.format(name)] = value
class StaticSelect2Multiple(StaticSelect2, forms.SelectMultiple):
@ -140,93 +123,31 @@ class APISelect(SelectWithDisabled):
A select widget populated via an API call
:param api_url: API endpoint URL. Required if not set automatically by the parent field.
:param display_field: (Optional) Field to display for child in selection list. Defaults to `name`.
:param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`.
:param disabled_indicator: (Optional) Mark option as disabled if this field equates true.
:param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the
name of the filter-for field (child field) and the value is the name of the query param filter.
:param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the
condition is met. The condition is the dict key and is specified in the form `<field_name>__<field_value>`.
If the provided field value is selected for the given field, the URL query param will be appended to
the rendered URL. The value is the in the from `<param_name>=<param_value>`. This is useful in cases where
a particular field value dictates an additional API filter.
:param additional_query_params: Optional) A dict of query params to append to the API request. The key is the
name of the query param and the value if the query param's value.
:param null_option: If true, include the static null option in the selection list.
"""
def __init__(
self,
api_url=None,
display_field=None,
value_field=None,
disabled_indicator=None,
filter_for=None,
conditional_query_params=None,
additional_query_params=None,
null_option=False,
full=False,
*args,
**kwargs
):
def __init__(self, api_url=None, full=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs['class'] = 'netbox-select2-api'
if api_url:
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
if full:
self.attrs['data-full'] = full
if display_field:
self.attrs['display-field'] = display_field
if value_field:
self.attrs['value-field'] = value_field
if disabled_indicator:
self.attrs['disabled-indicator'] = disabled_indicator
if filter_for:
for key, value in filter_for.items():
self.add_filter_for(key, value)
if conditional_query_params:
for key, value in conditional_query_params.items():
self.add_conditional_query_param(key, value)
if additional_query_params:
for key, value in additional_query_params.items():
self.add_additional_query_param(key, value)
if null_option:
self.attrs['data-null-option'] = 1
def add_filter_for(self, name, value):
"""
Add details for an additional query param in the form of a data-filter-for-* attribute.
:param name: The name of the query param
:param value: The value of the query param
"""
self.attrs['data-filter-for-{}'.format(name)] = value
def add_additional_query_param(self, name, value):
def add_query_param(self, name, value):
"""
Add details for an additional query param in the form of a data-* JSON-encoded list attribute.
:param name: The name of the query param
:param value: The value of the query param
"""
key = 'data-additional-query-param-{}'.format(name)
key = f'data-query-param-{name}'
values = json.loads(self.attrs.get(key, '[]'))
values.append(value)
if type(value) is list:
values.extend(value)
else:
values.append(value)
self.attrs[key] = json.dumps(values)
def add_conditional_query_param(self, condition, value):
"""
Add details for a URL query strings to append to the URL if the condition is met.
The condition is specified in the form `<field_name>__<field_value>`.
:param condition: The condition for the query param
:param value: The value of the query param
"""
self.attrs['data-conditional-query-param-{}'.format(condition)] = value
class APISelectMultiple(APISelect, forms.SelectMultiple):

View File

@ -13,10 +13,10 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkRenameForm, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, BulkRenameForm, CommentField,
ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -166,39 +166,27 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field='slug',
)
required=False
)
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field='slug',
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field='slug',
null_option=True,
)
null_option='None'
)
tag = TagFilterField(model)
@ -207,43 +195,32 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
widget=APISelect(
filter_for={
"site": "region_id",
},
attrs={
'nullable': 'true',
}
)
null_option='None'
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
filter_for={
"rack": "site_id",
"devices": "site_id",
}
)
query_params={
'region_id': '$region'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
widget=APISelect(
filter_for={
"devices": "rack_id"
},
attrs={
'nullable': 'true',
}
)
null_option='None',
display_field='display_name',
query_params={
'site_id': '$site'
}
)
devices = DynamicModelMultipleChoiceField(
queryset=Device.objects.filter(cluster__isnull=True),
widget=APISelectMultiple(
display_field='display_name',
disabled_indicator='cluster'
)
queryset=Device.objects.all(),
display_field='display_name',
query_params={
'site_id': '$site',
'rack_id': '$rack',
'cluster_id': 'null',
}
)
class Meta:
@ -288,26 +265,20 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
"cluster": "group_id",
},
attrs={
'nullable': 'true',
}
)
null_option='None'
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all()
queryset=Cluster.objects.all(),
query_params={
'group_id': '$cluster_group'
}
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
widget=APISelect(
additional_query_params={
"vm_role": "True"
}
)
query_params={
"vm_role": "True"
}
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
@ -444,11 +415,9 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
vm_role=True
),
required=False,
widget=APISelect(
additional_query_params={
"vm_role": "True"
}
)
query_params={
"vm_role": "True"
}
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -495,19 +464,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
cluster_type = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
@ -517,34 +480,25 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
filter_for={
'site': 'region'
}
)
required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None',
query_params={
'region': '$region'
}
)
role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.filter(vm_role=True),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
additional_query_params={
'vm_role': "True"
}
)
null_option='None',
query_params={
'vm_role': "True"
}
)
status = forms.MultipleChoiceField(
choices=VirtualMachineStatusChoices,
@ -555,10 +509,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
queryset=Platform.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
value_field="slug",
null_option=True,
)
null_option='None'
)
mac_address = forms.CharField(
required=False,
@ -575,24 +526,20 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@ -626,8 +573,8 @@ class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
# Add current site to VLANs query params
site = virtual_machine.site
if site:
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
def clean(self):
super().clean()
@ -679,24 +626,20 @@ class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@ -713,8 +656,8 @@ class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
# Add current site to VLANs query params
site = virtual_machine.site
if site:
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
class VMInterfaceCSVForm(CSVModelForm):
@ -773,24 +716,20 @@ class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelect(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
widget=APISelectMultiple(
display_field='display_name',
full=True,
additional_query_params={
'site_id': 'null',
},
)
display_field='display_name',
brief_mode=False,
query_params={
'site_id': 'null',
}
)
class Meta:
@ -808,8 +747,8 @@ class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
site = getattr(parent_obj.cluster, 'site', None)
if site is not None:
# Add current site to VLANs query params
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
class VMInterfaceBulkRenameForm(BulkRenameForm):
@ -824,17 +763,15 @@ class VMInterfaceFilterForm(forms.Form):
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label='Cluster',
widget=APISelectMultiple(
filter_for={
'virtual_machine_id': 'cluster_id'
}
)
label='Cluster'
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label='Virtual machine'
label='Virtual machine',
query_params={
'cluster_id': '$cluster_id'
}
)
enabled = forms.NullBooleanField(
required=False,