diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8f6b3ada8..0046a2be4 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,8 +7,8 @@ from rest_framework.validators import UniqueTogetherValidator from circuits.models import Circuit, CircuitTermination from dcim.constants import ( - CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, + CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, + RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -17,7 +17,7 @@ from dcim.models import ( RackReservation, RackRole, Region, Site, ) from extras.api.customfields import CustomFieldModelSerializer -from ipam.models import IPAddress +from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from virtualization.models import Cluster @@ -628,6 +628,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) @@ -635,12 +644,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): @@ -685,7 +697,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 1f5d50c4d..a8b4b1cf5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -9,14 +9,15 @@ from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField 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 .constants import ( @@ -31,6 +32,7 @@ from .models import ( Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, ) +from .constants import * DEVICE_BY_PK_RE = '{\d+\}' @@ -1601,11 +1603,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(), } @@ -1618,13 +1668,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) @@ -1633,6 +1735,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): @@ -1650,8 +1797,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) @@ -1660,9 +1840,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) @@ -1682,6 +1907,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/0050_interface_vlan_tagging.py b/netbox/dcim/migrations/0050_interface_vlan_tagging.py new file mode 100644 index 000000000..cb44054a7 --- /dev/null +++ b/netbox/dcim/migrations/0050_interface_vlan_tagging.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-10 20:10 +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', '0049_rackreservation_change_user'), + ] + + 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 84d4dc39c..1012924b7 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1244,6 +1244,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/dcim/views.py b/netbox/dcim/views.py index 674fa5b8f..a24720bce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1485,6 +1485,7 @@ class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): model = Interface parent_field = 'device' model_form = forms.InterfaceForm + template_name = 'dcim/interface_edit.html' class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): 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/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html new file mode 100644 index 000000000..648d73151 --- /dev/null +++ b/netbox/templates/dcim/interface_edit.html @@ -0,0 +1,28 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +