diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index f92c1f64e..4ab123fab 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -4,8 +4,9 @@ from collections import OrderedDict
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
from circuits.models import Circuit, CircuitTermination
+from dcim.constants import *
from dcim.models import (
CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
@@ -624,6 +625,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
]
+# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
+class InterfaceVLANSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+ class Meta:
+ model = VLAN
+ fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
class InterfaceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@@ -631,12 +641,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
is_connected = serializers.SerializerMethodField(read_only=True)
interface_connection = serializers.SerializerMethodField(read_only=True)
circuit_termination = InterfaceCircuitTerminationSerializer()
+ untagged_vlan = InterfaceVLANSerializer()
+ mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+ tagged_vlans = InterfaceVLANSerializer(many=True)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
- 'is_connected', 'interface_connection', 'circuit_termination',
+ 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
]
def get_is_connected(self, obj):
@@ -681,7 +694,37 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+ 'mode', 'untagged_vlan', 'tagged_vlans',
]
+ ignore_validation_fields = [
+ 'tagged_vlans'
+ ]
+
+ def validate(self, data):
+
+ # Get the device for later use
+ if self.instance:
+ device = self.instance.device
+ else:
+ device = data.get('device')
+
+ # Validate VLANs belong to the device's site or global
+ # We have to do this here decause of the ManyToMany relationship
+ native_vlan = data.get('native_vlan')
+ if native_vlan:
+ if native_vlan.site != device.site and native_vlan.site is not None:
+ raise serializers.ValidationError("Native VLAN is invalid for the interface's device.")
+
+ tagged_vlan_members = data.get('tagged_vlan_members')
+ if tagged_vlan_members:
+ for vlan in tagged_vlan_members:
+ if vlan.site != device.site and vlan.site is not None:
+ raise serializers.ValidationError("Tagged VLAN {} is invalid for the interface's device.".format(vlan))
+
+ # Enforce model validation
+ super(WritableInterfaceSerializer, self).validate(data)
+
+ return data
#
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index b355e6c85..1948f32a2 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -193,6 +193,15 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
+IFACE_MODE_ACCESS = 100
+IFACE_MODE_TAGGED = 200
+IFACE_MODE_TAGGED_ALL = 300
+IFACE_MODE_CHOICES = [
+ [IFACE_MODE_ACCESS, 'Access'],
+ [IFACE_MODE_TAGGED, 'Tagged'],
+ [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
+]
+
# Device statuses
STATUS_OFFLINE = 0
STATUS_ACTIVE = 1
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index c4ec531f3..e730a05d9 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -9,14 +9,15 @@ from django.contrib.postgres.forms.array import SimpleArrayField
from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN, VLANGroup
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
- APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
- ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
- ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
- SlugField, FilterTreeNodeMultipleChoiceField,
+ APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
+ BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
+ CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
+ FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+ FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .formfields import MACAddressFormField
@@ -28,6 +29,7 @@ from .models import (
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
)
+from .constants import *
DEVICE_BY_PK_RE = '{\d+\}'
@@ -1599,11 +1601,59 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
# Interfaces
#
-class InterfaceForm(BootstrapMixin, forms.ModelForm):
+class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
+
+ site = forms.ModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ label='VLAN Site',
+ widget=forms.Select(
+ attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+ )
+ )
+ vlan_group = ChainedModelChoiceField(
+ queryset=VLANGroup.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ),
+ required=False,
+ label='VLAN group',
+ widget=APISelect(
+ attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+ api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+ )
+ )
+ untagged_vlan = ChainedModelChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Untagged VLAN',
+ widget=APISelect(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
+ tagged_vlans = ChainedModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Tagged VLANs',
+ widget=APISelectMultiple(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
class Meta:
model = Interface
- fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description']
+ fields = [
+ 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only',
+ 'description', 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
+ ]
widgets = {
'device': forms.HiddenInput(),
}
@@ -1616,13 +1666,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device_id=self.data['device'], form_factor=IFACE_FF_LAG
)
+ device = Device.objects.get(pk=self.data['device'])
else:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.instance.device, form_factor=IFACE_FF_LAG
)
+ device = self.instance.device
+
+ # Limit the queryset for the site to only include the interface's device's site
+ if device and device.site:
+ self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
+ self.fields['site'].initial = None
+ else:
+ self.fields['site'].queryset = Site.objects.none()
+ self.fields['site'].initial = None
+
+ # Limit the initial vlan choices
+ if self.is_bound:
+ filter_dict = {
+ 'group_id': self.data.get('vlan_group') or None,
+ 'site_id': self.data.get('site') or None,
+ }
+ elif self.initial.get('untagged_vlan'):
+ filter_dict = {
+ 'group_id': self.instance.untagged_vlan.group,
+ 'site_id': self.instance.untagged_vlan.site,
+ }
+ elif self.initial.get('tagged_vlans'):
+ filter_dict = {
+ 'group_id': self.instance.tagged_vlans.first().group,
+ 'site_id': self.instance.tagged_vlans.first().site,
+ }
+ else:
+ filter_dict = {
+ 'group_id': None,
+ 'site_id': None,
+ }
+
+ self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+ self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
+ def clean_tagged_vlans(self):
+ """
+ Becasue tagged_vlans is a many-to-many relationship, validation must be done in the form
+ """
+ if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
+ raise forms.ValidationError(
+ "An Access interface cannot have tagged VLANs."
+ )
+
+ if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
+ raise forms.ValidationError(
+ "Interface mode Tagged All implies all VLANs are tagged. "
+ "Do not select any tagged VLANs."
+ )
+
+ return self.cleaned_data['tagged_vlans']
-class InterfaceCreateForm(ComponentForm):
+class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
@@ -1631,6 +1733,51 @@ class InterfaceCreateForm(ComponentForm):
mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False)
+ mode = forms.ChoiceField(choices=IFACE_MODE_CHOICES)
+ site = forms.ModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ label='VLAN Site',
+ widget=forms.Select(
+ attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+ )
+ )
+ vlan_group = ChainedModelChoiceField(
+ queryset=VLANGroup.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ),
+ required=False,
+ label='VLAN group',
+ widget=APISelect(
+ attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+ api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+ )
+ )
+ untagged_vlan = ChainedModelChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Untagged VLAN',
+ widget=APISelect(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
+ tagged_vlans = ChainedModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Tagged VLANs',
+ widget=APISelectMultiple(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
def __init__(self, *args, **kwargs):
@@ -1648,8 +1795,41 @@ class InterfaceCreateForm(ComponentForm):
else:
self.fields['lag'].queryset = Interface.objects.none()
+ # Limit the queryset for the site to only include the interface's device's site
+ if self.parent is not None and self.parent.site:
+ self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
+ self.fields['site'].initial = None
+ else:
+ self.fields['site'].queryset = Site.objects.none()
+ self.fields['site'].initial = None
-class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+ # Limit the initial vlan choices
+ if self.is_bound:
+ filter_dict = {
+ 'group_id': self.data.get('vlan_group') or None,
+ 'site_id': self.data.get('site') or None,
+ }
+ elif self.initial.get('untagged_vlan'):
+ filter_dict = {
+ 'group_id': self.untagged_vlan.group,
+ 'site_id': self.untagged_vlan.site,
+ }
+ elif self.initial.get('tagged_vlans'):
+ filter_dict = {
+ 'group_id': self.tagged_vlans.first().group,
+ 'site_id': self.tagged_vlans.first().site,
+ }
+ else:
+ filter_dict = {
+ 'group_id': None,
+ 'site_id': None,
+ }
+
+ self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+ self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
+
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
@@ -1658,9 +1838,54 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
description = forms.CharField(max_length=100, required=False)
+ mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
+ site = forms.ModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ label='VLAN Site',
+ widget=forms.Select(
+ attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
+ )
+ )
+ vlan_group = ChainedModelChoiceField(
+ queryset=VLANGroup.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ),
+ required=False,
+ label='VLAN group',
+ widget=APISelect(
+ attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
+ api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+ )
+ )
+ untagged_vlan = ChainedModelChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Untagged VLAN',
+ widget=APISelect(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
+ tagged_vlans = ChainedModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ chains=(
+ ('site', 'site'),
+ ('group', 'vlan_group'),
+ ),
+ required=False,
+ label='Tagged VLANs',
+ widget=APISelectMultiple(
+ api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
+ )
+ )
class Meta:
- nullable_fields = ['lag', 'mtu', 'description']
+ nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1680,6 +1905,22 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
else:
self.fields['lag'].choices = []
+ # Limit the queryset for the site to only include the interface's device's site
+ if device and device.site:
+ self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
+ self.fields['site'].initial = None
+ else:
+ self.fields['site'].queryset = Site.objects.none()
+ self.fields['site'].initial = None
+
+ filter_dict = {
+ 'group_id': None,
+ 'site_id': None,
+ }
+
+ self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
+ self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+
class InterfaceBulkDisconnectForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
diff --git a/netbox/dcim/migrations/0049_interface_vlan_tagging.py b/netbox/dcim/migrations/0049_interface_vlan_tagging.py
new file mode 100644
index 000000000..a215bf6db
--- /dev/null
+++ b/netbox/dcim/migrations/0049_interface_vlan_tagging.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-10-13 20:22
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0020_ipaddress_add_role_carp'),
+ ('dcim', '0048_rack_serial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interface',
+ name='mode',
+ field=models.PositiveSmallIntegerField(choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], default=100),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='tagged_vlans',
+ field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='untagged_vlan',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
+ ),
+ ]
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index 0ff1434c0..e193cc4c6 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -1226,6 +1226,20 @@ class Interface(models.Model):
help_text="This interface is used only for out-of-band management"
)
description = models.CharField(max_length=100, blank=True)
+ mode = models.PositiveSmallIntegerField(choices=IFACE_MODE_CHOICES, default=IFACE_MODE_ACCESS)
+ untagged_vlan = models.ForeignKey(
+ to='ipam.VLAN',
+ null=True,
+ blank=True,
+ verbose_name='Untagged VLAN',
+ related_name='interfaces_as_untagged'
+ )
+ tagged_vlans = models.ManyToManyField(
+ to='ipam.VLAN',
+ blank=True,
+ verbose_name='Tagged VLANs',
+ related_name='interfaces_as_tagged'
+ )
objects = InterfaceQuerySet.as_manager()
diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js
index 945a94d45..50ee20e16 100644
--- a/netbox/project-static/js/forms.js
+++ b/netbox/project-static/js/forms.js
@@ -71,59 +71,65 @@ $(document).ready(function() {
$('select[filter-for]').change(function() {
// Resolve child field by ID specified in parent
- var child_name = $(this).attr('filter-for');
- var child_field = $('#id_' + child_name);
- var child_selected = child_field.val();
+ var child_names = $(this).attr('filter-for');
+ var parent = this;
- // Wipe out any existing options within the child field and create a default option
- child_field.empty();
- if (!child_field.attr('multiple')) {
- child_field.append($("").attr("value", "").text("---------"));
- }
+ // allow more than one child
+ $.each(child_names.split(" "), function(_, child_name){
- if ($(this).val() || $(this).attr('nullable') == 'true') {
- var api_url = child_field.attr('api-url') + '&limit=1000';
- var disabled_indicator = child_field.attr('disabled-indicator');
- var initial_value = child_field.attr('initial');
- var display_field = child_field.attr('display-field') || 'name';
+ var child_field = $('#id_' + child_name);
+ var child_selected = child_field.val();
- // Determine the filter fields needed to make an API call
- var filter_regex = /\{\{([a-z_]+)\}\}/g;
- var match;
- var rendered_url = api_url;
- while (match = filter_regex.exec(api_url)) {
- var filter_field = $('#id_' + match[1]);
- if (filter_field.val()) {
- rendered_url = rendered_url.replace(match[0], filter_field.val());
- } else if (filter_field.attr('nullable') == 'true') {
- rendered_url = rendered_url.replace(match[0], '0');
- }
+ // Wipe out any existing options within the child field and create a default option
+ child_field.empty();
+ if (!child_field.attr('multiple')) {
+ child_field.append($("").attr("value", "").text("---------"));
}
- // If all URL variables have been replaced, make the API call
- if (rendered_url.search('{{') < 0) {
- console.log(child_name + ": Fetching " + rendered_url);
- $.ajax({
- url: rendered_url,
- dataType: 'json',
- success: function(response, status) {
- $.each(response.results, function(index, choice) {
- var option = $("").attr("value", choice.id).text(choice[display_field]);
- if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
- option.attr("disabled", "disabled");
- } else if (choice.id == child_selected) {
- option.attr("selected", "selected");
- }
- child_field.append(option);
- });
+ if ($(parent).val() || $(parent).attr('nullable') == 'true') {
+ var api_url = child_field.attr('api-url') + '&limit=1000';
+ var disabled_indicator = child_field.attr('disabled-indicator');
+ var initial_value = child_field.attr('initial');
+ var display_field = child_field.attr('display-field') || 'name';
+
+ // Determine the filter fields needed to make an API call
+ var filter_regex = /\{\{([a-z_]+)\}\}/g;
+ var match;
+ var rendered_url = api_url;
+ while (match = filter_regex.exec(api_url)) {
+ var filter_field = $('#id_' + match[1]);
+ if (filter_field.val()) {
+ rendered_url = rendered_url.replace(match[0], filter_field.val());
+ } else if (filter_field.attr('nullable') == 'true') {
+ rendered_url = rendered_url.replace(match[0], '0');
}
- });
+ }
+
+ // If all URL variables have been replaced, make the API call
+ if (rendered_url.search('{{') < 0) {
+ console.log(child_name + ": Fetching " + rendered_url);
+ $.ajax({
+ url: rendered_url,
+ dataType: 'json',
+ success: function(response, status) {
+ $.each(response.results, function(index, choice) {
+ var option = $("").attr("value", choice.id).text(choice[display_field]);
+ if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
+ option.attr("disabled", "disabled");
+ } else if (choice.id == child_selected) {
+ option.attr("selected", "selected");
+ }
+ child_field.append(option);
+ });
+ }
+ });
+ }
+
}
- }
-
- // Trigger change event in case the child field is the parent of another field
- child_field.change();
+ // Trigger change event in case the child field is the parent of another field
+ child_field.change();
+ });
});
});
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index d9ae855ea..f35e6b40a 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -48,6 +48,11 @@ class ValidatedModelSerializer(ModelSerializer):
attrs = data.copy()
attrs.pop('custom_fields', None)
+ # remove any fields marked for no validation
+ ignore_validation_fields = getattr(self.Meta, 'ignore_validation_fields', [])
+ for field in ignore_validation_fields:
+ attrs.pop(field)
+
# Run clean() on an instance of the model
if self.instance is None:
instance = self.Meta.model(**attrs)
diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py
new file mode 100644
index 000000000..1cb3999ef
--- /dev/null
+++ b/netbox/utilities/constants.py
@@ -0,0 +1,7 @@
+from utilities.forms import ChainedModelMultipleChoiceField
+
+
+# Fields which are used on ManyToMany relationships
+M2M_FIELD_TYPES = [
+ ChainedModelMultipleChoiceField,
+]
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index d7b82236c..b685fafb7 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -766,6 +766,26 @@ class ComponentCreateView(View):
if not form.errors:
self.model.objects.bulk_create(new_components)
+
+ # ManyToMany relations are bulk created via the through model
+ m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
+ if m2m_fields:
+ for field in m2m_fields:
+ field_links = []
+ for new_component in new_components:
+ for related_obj in component_form.cleaned_data[field]:
+ # The through model columns are the id's of our M2M relation objects
+ through_kwargs = {}
+ new_component_column = new_component.__class__.__name__ + '_id'
+ related_obj_column = related_obj.__class__.__name__ + '_id'
+ through_kwargs.update({
+ new_component_column.lower(): new_component.id,
+ related_obj_column.lower(): related_obj.id
+ })
+ field_link = getattr(self.model, field).through(**through_kwargs)
+ field_links.append(field_link)
+ getattr(self.model, field).through.objects.bulk_create(field_links)
+
messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 8fb13c7cc..fcb860110 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -255,3 +255,9 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self):
return VM_STATUS_CLASSES[self.status]
+
+ @property
+ def site(self):
+ # used when a child compent (eg Interface) needs to know its parent's site but
+ # the parent could be either a device or a virtual machine
+ return self.cluster.site