diff --git a/netbox/templates/ipam/vlan_members.html b/netbox/templates/ipam/vlan_members.html
new file mode 100644
index 000000000..27d5d50f7
--- /dev/null
+++ b/netbox/templates/ipam/vlan_members.html
@@ -0,0 +1,12 @@
+{% extends '_base.html' %}
+
+{% block title %}{{ vlan }} - Members{% endblock %}
+
+{% block content %}
+ {% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
+
+
+ {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
+
+
+{% endblock %}
diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html
index 2b24208fd..16acc32ed 100644
--- a/netbox/templates/utilities/obj_edit.html
+++ b/netbox/templates/utilities/obj_edit.html
@@ -31,13 +31,15 @@
- {% if obj.pk %}
-
- {% else %}
-
-
- {% endif %}
-
Cancel
+ {% block buttons %}
+ {% if obj.pk %}
+
+ {% else %}
+
+
+ {% endif %}
+
Cancel
+ {% endblock %}
diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/interface_edit.html
new file mode 100644
index 000000000..b3aa38fd3
--- /dev/null
+++ b/netbox/templates/virtualization/interface_edit.html
@@ -0,0 +1,53 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+
+
Interface
+
+ {% render_field form.name %}
+ {% render_field form.enabled %}
+ {% render_field form.mac_address %}
+ {% render_field form.mtu %}
+ {% render_field form.description %}
+ {% render_field form.mode %}
+
+
+ {% if obj.mode %}
+
+
802.1Q VLANs
+ {% include 'dcim/inc/interface_vlans_table.html' %}
+
+
+ {% endif %}
+{% endblock %}
+
+{% block buttons %}
+ {% if obj.pk %}
+
+
+ {% else %}
+
+
+ {% endif %}
+
Cancel
+{% endblock %}
+
+{% block javascript %}
+
+{% endblock %}
diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py
new file mode 100644
index 000000000..b97506b85
--- /dev/null
+++ b/netbox/utilities/custom_inspectors.py
@@ -0,0 +1,76 @@
+from drf_yasg import openapi
+from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
+from rest_framework.fields import ChoiceField
+
+from extras.api.customfields import CustomFieldsSerializer
+from utilities.api import ChoiceFieldSerializer
+
+
+class CustomChoiceFieldInspector(FieldInspector):
+ def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
+ # this returns a callable which extracts title, description and other stuff
+ # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
+ SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
+
+ if isinstance(field, ChoiceFieldSerializer):
+ value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
+
+ choices = list(field._choices.keys())
+ if set([None] + choices) == {None, True, False}:
+ # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be
+ # differentiated since they each have subtly different values in their choice keys.
+ # - subdevice_role and connection_status are booleans, although subdevice_role includes None
+ # - face is an integer set {0, 1} which is easily confused with {False, True}
+ schema_type = openapi.TYPE_INTEGER
+ if all(type(x) == bool for x in [c for c in choices if c is not None]):
+ schema_type = openapi.TYPE_BOOLEAN
+ value_schema = openapi.Schema(type=schema_type)
+ value_schema['x-nullable'] = True
+
+ schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={
+ "label": openapi.Schema(type=openapi.TYPE_STRING),
+ "value": value_schema
+ })
+
+ return schema
+
+ elif isinstance(field, CustomFieldsSerializer):
+ schema = SwaggerType(type=openapi.TYPE_OBJECT)
+ return schema
+
+ return NotHandled
+
+
+class NullableBooleanFieldInspector(FieldInspector):
+ def process_result(self, result, method_name, obj, **kwargs):
+
+ if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean':
+ keys = obj.choices.keys()
+ if set(keys) == {None, True, False}:
+ result['x-nullable'] = True
+ result.type = 'boolean'
+
+ return result
+
+
+class IdInFilterInspector(FilterInspector):
+ def process_result(self, result, method_name, obj, **kwargs):
+ if isinstance(result, list):
+ params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in']
+ for p in params:
+ p.type = 'string'
+
+ return result
+
+
+class NullablePaginatorInspector(PaginatorInspector):
+ def process_result(self, result, method_name, obj, **kwargs):
+ if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
+ next = result.properties['next']
+ if isinstance(next, openapi.Schema):
+ next['x-nullable'] = True
+ previous = result.properties['previous']
+ if isinstance(previous, openapi.Schema):
+ previous['x-nullable'] = True
+
+ return result
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index a2bfef001..15fb69f7f 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -6,6 +6,7 @@ import re
from django import forms
from django.conf import settings
+from django.db.models import Count
from django.urls import reverse_lazy
from mptt.forms import TreeNodeMultipleChoiceField
@@ -38,6 +39,7 @@ COLOR_CHOICES = (
('111111', 'Black'),
)
NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
+ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
@@ -76,6 +78,45 @@ def expand_numeric_pattern(string):
yield "{}{}{}".format(lead, i, remnant)
+def parse_alphanumeric_range(string):
+ """
+ Expand an alphanumeric range (continuous or not) into a list.
+ 'a-d,f' => [a, b, c, d, f]
+ '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
+ """
+ values = []
+ for dash_range in string.split(','):
+ try:
+ begin, end = dash_range.split('-')
+ vals = begin + end
+ # Break out of loop if there's an invalid pattern to return an error
+ if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
+ return []
+ except ValueError:
+ begin, end = dash_range, dash_range
+ if begin.isdigit() and end.isdigit():
+ for n in list(range(int(begin), int(end) + 1)):
+ values.append(n)
+ else:
+ for n in list(range(ord(begin), ord(end) + 1)):
+ values.append(chr(n))
+ return values
+
+
+def expand_alphanumeric_pattern(string):
+ """
+ Expand an alphabetic pattern into a list of strings.
+ """
+ lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
+ parsed_range = parse_alphanumeric_range(pattern)
+ for i in parsed_range:
+ if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
+ for string in expand_alphanumeric_pattern(remnant):
+ yield "{}{}{}".format(lead, i, string)
+ else:
+ yield "{}{}{}".format(lead, i, remnant)
+
+
def expand_ipaddress_pattern(string, family):
"""
Expand an IP address pattern into a list of strings. Examples:
@@ -305,12 +346,15 @@ class ExpandableNameField(forms.CharField):
def __init__(self, *args, **kwargs):
super(ExpandableNameField, self).__init__(*args, **kwargs)
if not self.help_text:
- self.help_text = 'Numeric ranges are supported for bulk creation.
'\
- 'Example:
ge-0/0/[0-23,25,30]
'
+ self.help_text = 'Alphanumeric ranges are supported for bulk creation.
' \
+ 'Mixed cases and types within a single range are not supported.
' \
+ 'Examples:
ge-0/0/[0-23,25,30]
' \
+ 'e[0-3][a-d,f]
' \
+ 'e[0-3,a-d,f]
'
def to_python(self, value):
- if re.search(NUMERIC_EXPANSION_PATTERN, value):
- return list(expand_numeric_pattern(value))
+ if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
+ return list(expand_alphanumeric_pattern(value))
return [value]
@@ -450,6 +494,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
pass
+class AnnotatedMultipleChoiceField(forms.MultipleChoiceField):
+ """
+ Render a set of static choices with each choice annotated to include a count of related objects. For example, this
+ field can be used to display a list of all available device statuses along with the number of devices currently
+ assigned to each status.
+ """
+
+ def annotate_choices(self):
+ queryset = self.annotate.values(
+ self.annotate_field
+ ).annotate(
+ count=Count(self.annotate_field)
+ ).order_by(
+ self.annotate_field
+ )
+ choice_counts = {
+ c[self.annotate_field]: c['count'] for c in queryset
+ }
+ annotated_choices = [
+ (c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices
+ ]
+
+ return annotated_choices
+
+ def __init__(self, choices, annotate, annotate_field, *args, **kwargs):
+ self.annotate = annotate
+ self.annotate_field = annotate_field
+ self.static_choices = choices
+
+ super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs)
+
+
class LaxURLField(forms.URLField):
"""
Modifies Django's built-in URLField in two ways:
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 06b992203..4dfea1b42 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError
from django.db.models import Count
from mptt.forms import TreeNodeChoiceField
-from dcim.constants import IFACE_FF_VIRTUAL
+from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
+from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -13,9 +14,9 @@ from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
- add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+ AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
- ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
+ ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
)
from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
-def vm_status_choices():
- status_counts = {}
- for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
- status_counts[status['status']] = status['count']
- return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
-
-
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VirtualMachine
q = forms.CharField(required=False, label='Search')
@@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_label='-- None --'
)
- status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
+ status = AnnotatedMultipleChoiceField(
+ choices=VM_STATUS_CHOICES,
+ annotate=VirtualMachine.objects.all(),
+ annotate_field='status',
+ required=False
+ )
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
to_field_name='slug',
@@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
- fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
+ fields = [
+ 'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+ 'untagged_vlan', 'tagged_vlans',
+ ]
widgets = {
'virtual_machine': forms.HiddenInput(),
'form_factor': forms.HiddenInput(),
}
+ labels = {
+ 'mode': '802.1Q Mode',
+ }
+ help_texts = {
+ 'mode': INTERFACE_MODE_HELP_TEXT,
+ }
+
+ def clean(self):
+
+ super(InterfaceForm, self).clean()
+
+ # Validate VLAN assignments
+ tagged_vlans = self.cleaned_data['tagged_vlans']
+
+ # Untagged interfaces cannot be assigned tagged VLANs
+ if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
+ raise forms.ValidationError({
+ 'mode': "An access interface cannot have tagged VLANs assigned."
+ })
+
+ # Remove all tagged VLAN assignments from "tagged all" interfaces
+ elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
+ self.cleaned_data['tagged_vlans'] = []
class InterfaceCreateForm(ComponentForm):
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index d8c168a7a..0a6abc400 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
else:
return None
+ @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
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 82267fc00..6de6b86c7 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -329,6 +329,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
model_form = forms.InterfaceForm
+ template_name = 'virtualization/interface_edit.html'
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
diff --git a/old_requirements.txt b/old_requirements.txt
index 610ec6c44..b3f7b3c47 100644
--- a/old_requirements.txt
+++ b/old_requirements.txt
@@ -1,2 +1,3 @@
+django-rest-swagger
psycopg2
pycrypto
diff --git a/requirements.txt b/requirements.txt
index 89c880815..5b7b3e73e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,10 +3,10 @@ django-cors-headers>=2.1.0
django-debug-toolbar>=1.9.0
django-filter>=1.1.0
django-mptt>=0.9.0
-django-rest-swagger>=2.1.0
django-tables2>=1.19.0
django-timezone-field>=2.0
djangorestframework>=3.7.7
+drf-yasg[validation]>=1.4.4
graphviz>=0.8.2
Markdown>=2.6.11
natsort>=5.2.0