mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-21 03:27:21 -06:00
commit
68f73c7f94
@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
|
||||
@ -41,3 +41,4 @@ and run `upgrade.sh`.
|
||||
|
||||
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
|
@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
||||
ProxyPass !
|
||||
</Location>
|
||||
|
||||
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||
ProxyPass / http://127.0.0.1:8001/
|
||||
ProxyPassReverse / http://127.0.0.1:8001/
|
||||
</VirtualHost>
|
||||
@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
||||
```no-highlight
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2enmod headers
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
|
||||
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
|
||||
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
)
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
|
||||
|
||||
|
||||
def circuit_status_choices():
|
||||
status_counts = {}
|
||||
for status in Circuit.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 CIRCUIT_STATUS_CHOICES]
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
annotate=Circuit.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug',
|
||||
|
@ -6,6 +6,9 @@ from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.openapi import Parameter
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.response import Response
|
||||
@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
* `peer-interface`: The name of the peer interface
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
_device_param = Parameter('peer-device', 'query',
|
||||
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
|
||||
_interface_param = Parameter('peer-interface', 'query',
|
||||
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
|
||||
|
||||
def get_view_name(self):
|
||||
return "Connected Device Locator"
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
|
||||
def list(self, request):
|
||||
|
||||
peer_device_name = request.query_params.get('peer-device')
|
||||
peer_interface_name = request.query_params.get('peer-interface')
|
||||
peer_device_name = request.query_params.get(self._device_param.name)
|
||||
peer_interface_name = request.query_params.get(self._interface_param.name)
|
||||
if not peer_device_name or not peer_interface_name:
|
||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
||||
|
||||
|
@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
|
||||
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
|
||||
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
|
||||
SmallTextarea, SlugField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
@ -37,6 +36,12 @@ from .models import (
|
||||
|
||||
DEVICE_BY_PK_RE = '{\d+\}'
|
||||
|
||||
INTERFACE_MODE_HELP_TEXT = """
|
||||
Access: One untagged VLAN<br />
|
||||
Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
|
||||
Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
|
||||
"""
|
||||
|
||||
|
||||
def get_device_by_name_or_pk(name):
|
||||
"""
|
||||
@ -172,17 +177,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
|
||||
|
||||
|
||||
def site_status_choices():
|
||||
status_counts = {}
|
||||
for status in Site.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 SITE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
annotate=Site.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||
to_field_name='slug',
|
||||
@ -700,13 +703,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class PlatformCSVForm(forms.ModelForm):
|
||||
slug = SlugField()
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=True,
|
||||
to_field_name='name',
|
||||
help_text='Manufacturer name',
|
||||
error_messages={
|
||||
'invalid_choice': 'Manufacturer not found.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = Platform.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Platform name',
|
||||
'manufacturer': 'Manufacturer name',
|
||||
}
|
||||
|
||||
|
||||
@ -1040,13 +1051,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'platform', 'serial']
|
||||
|
||||
|
||||
def device_status_choices():
|
||||
status_counts = {}
|
||||
for status in Device.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 DEVICE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@ -1084,7 +1088,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
annotate=Device.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
mac_address = forms.CharField(required=False, label='MAC address')
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
@ -1648,63 +1657,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
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}}',
|
||||
display_field='display_name'
|
||||
)
|
||||
)
|
||||
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}}',
|
||||
display_field='display_name'
|
||||
)
|
||||
)
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
labels = {
|
||||
'mode': '802.1Q Mode',
|
||||
}
|
||||
help_texts = {
|
||||
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceForm, self).__init__(*args, **kwargs)
|
||||
@ -1721,58 +1690,108 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
||||
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
|
||||
# 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
|
||||
def clean(self):
|
||||
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group'),
|
||||
'site_id': self.data.get('site'),
|
||||
}
|
||||
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,
|
||||
}
|
||||
super(InterfaceForm, self).clean()
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
# Validate VLAN assignments
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
|
||||
def clean_tagged_vlans(self):
|
||||
"""
|
||||
Because 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."
|
||||
# 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 InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
|
||||
vlans = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='VLANs',
|
||||
widget=forms.SelectMultiple(attrs={'size': 20})
|
||||
)
|
||||
tagged = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.mode == IFACE_MODE_ACCESS:
|
||||
self.initial['tagged'] = False
|
||||
|
||||
# Find all VLANs already assigned to the interface for exclusion from the list
|
||||
assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
|
||||
if self.instance.untagged_vlan is not None:
|
||||
assigned_vlans.append(self.instance.untagged_vlan.pk)
|
||||
|
||||
# Compile VLAN choices
|
||||
vlan_choices = []
|
||||
|
||||
# Add global VLANs
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
|
||||
# Add grouped global VLANs
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_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."
|
||||
)
|
||||
parent = self.instance.parent
|
||||
if parent is not None:
|
||||
|
||||
return self.cleaned_data['tagged_vlans']
|
||||
# Add site VLANs
|
||||
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=parent.site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['vlans'].choices = vlan_choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).clean()
|
||||
|
||||
# Only untagged VLANs permitted on an access interface
|
||||
if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
|
||||
|
||||
# 'tagged' is required if more than one VLAN is selected
|
||||
if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one untagged VLAN may be selected.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
if self.cleaned_data['tagged']:
|
||||
for vlan in self.cleaned_data['vlans']:
|
||||
self.instance.tagged_vlans.add(vlan)
|
||||
else:
|
||||
self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
|
||||
|
||||
return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
class InterfaceCreateForm(ComponentForm, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
@ -1786,50 +1805,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
)
|
||||
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}}',
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@ -1847,41 +1822,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
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
|
||||
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group'),
|
||||
'site_id': self.data.get('site'),
|
||||
}
|
||||
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):
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||
@ -1890,53 +1832,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
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', 'untagged_vlan', 'tagged_vlans']
|
||||
nullable_fields = ['lag', 'mtu', 'description', 'mode']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
@ -1951,28 +1849,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
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
|
||||
|
||||
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group'),
|
||||
'site_id': self.data.get('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 InterfaceBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
@ -1455,6 +1455,18 @@ class Interface(models.Model):
|
||||
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
if self.mode is None:
|
||||
self.untagged_vlan = None
|
||||
|
||||
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||
if self.pk and self.mode is not IFACE_MODE_TAGGED:
|
||||
self.tagged_vlans.clear()
|
||||
|
||||
return super(Interface, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
|
@ -47,8 +47,13 @@ REGION_ACTIONS = """
|
||||
"""
|
||||
|
||||
RACKGROUP_ACTIONS = """
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackgroup %}
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """
|
||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
DEVICETYPE_INSTANCES_TEMPLATE = """
|
||||
<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
@ -182,12 +191,21 @@ class SiteTable(BaseTable):
|
||||
|
||||
class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')],
|
||||
verbose_name='Site'
|
||||
)
|
||||
rack_count = tables.Column(
|
||||
verbose_name='Racks'
|
||||
)
|
||||
slug = tables.Column()
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKGROUP_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable):
|
||||
|
||||
class DeviceTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
model = tables.LinkColumn(
|
||||
viewname='dcim:devicetype',
|
||||
args=[Accessor('pk')],
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
||||
instance_count = tables.Column(verbose_name='Instances')
|
||||
subdevice_role = tables.TemplateColumn(
|
||||
template_code=SUBDEVICE_ROLE_TEMPLATE,
|
||||
verbose_name='Subdevice Role'
|
||||
)
|
||||
instance_count = tables.TemplateColumn(
|
||||
template_code=DEVICETYPE_INSTANCES_TEMPLATE,
|
||||
verbose_name='Instances'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
|
@ -5,7 +5,9 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
|
||||
from dcim.constants import (
|
||||
IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
|
||||
)
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
|
||||
'untagged_vlan': self.vlan3.id
|
||||
}
|
||||
@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 5',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 6',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
|
@ -185,6 +185,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||
|
||||
|
@ -962,11 +962,9 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
interfaces = Interface.objects.order_naturally(
|
||||
interfaces = device.vc_interfaces.order_naturally(
|
||||
device.device_type.interface_ordering
|
||||
).connectable().filter(
|
||||
device=device
|
||||
).select_related(
|
||||
).connectable().select_related(
|
||||
'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
|
||||
@ -1645,6 +1643,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
template_name = 'dcim/interface_edit.html'
|
||||
|
||||
|
||||
class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
model_form = forms.InterfaceAssignVLANsForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
@ -2226,7 +2230,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
||||
device = member_select_form.cleaned_data['device']
|
||||
device.virtual_chassis = virtual_chassis
|
||||
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
|
||||
membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device)
|
||||
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
|
||||
|
||||
if membership_form.is_valid():
|
||||
|
||||
@ -2242,7 +2246,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
||||
|
||||
else:
|
||||
|
||||
membership_form = forms.DeviceVCMembershipForm(request.POST)
|
||||
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
|
||||
|
||||
return render(request, 'dcim/virtualchassis_add_member.html', {
|
||||
'virtual_chassis': virtual_chassis,
|
||||
|
@ -127,7 +127,7 @@ class CustomField(models.Model):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value is '':
|
||||
if serialized_value == '':
|
||||
return None
|
||||
if self.type == CF_TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
|
@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
|
||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
||||
add_blank_choice,
|
||||
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
|
||||
SlugField, add_blank_choice,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
||||
@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def prefix_status_choices():
|
||||
status_counts = {}
|
||||
for status in Prefix.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 PREFIX_STATUS_CHOICES]
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=PREFIX_STATUS_CHOICES,
|
||||
annotate=Prefix.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
||||
to_field_name='slug',
|
||||
@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
address = forms.CharField(label='IP Address')
|
||||
|
||||
|
||||
def ipaddress_status_choices():
|
||||
status_counts = {}
|
||||
for status in IPAddress.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 IPADDRESS_STATUS_CHOICES]
|
||||
|
||||
|
||||
def ipaddress_role_choices():
|
||||
role_counts = {}
|
||||
for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
|
||||
role_counts[role['role']] = role['count']
|
||||
return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = IPAddress
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
||||
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_STATUS_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
role = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_ROLE_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='role',
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@ -878,13 +872,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
status_counts = {}
|
||||
for status in VLAN.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 VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=VLAN_STATUS_CHOICES,
|
||||
annotate=VLAN.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
||||
to_field_name='slug',
|
||||
|
@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
@ -365,7 +366,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
else:
|
||||
child_count = self.get_child_ips().count()
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||
prefix_size = self.prefix.size
|
||||
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
def get_members(self):
|
||||
# Return all interfaces assigned to this VLAN
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Service(CreatedUpdatedModel):
|
||||
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_UNTAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
<i class="glyphicon glyphicon-ok">
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_ACTIONS = """
|
||||
{% if perms.dcim.change_interface %}
|
||||
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TENANT_LINK = """
|
||||
{% if record.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
||||
@ -361,3 +374,21 @@ class VLANDetailTable(VLANTable):
|
||||
|
||||
class Meta(VLANTable.Meta):
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
|
||||
name = tables.Column(verbose_name='Interface')
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
orderable=False
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('parent', 'name', 'untagged', 'actions')
|
||||
|
@ -80,6 +80,7 @@ urlpatterns = [
|
||||
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
|
||||
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
|
||||
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
|
||||
|
@ -851,6 +851,38 @@ class VLANView(View):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
|
||||
members = vlan.get_members().select_related('device', 'virtual_machine')
|
||||
|
||||
members_table = tables.VLANMemberTable(members)
|
||||
# if request.user.has_perm('dcim.change_interface'):
|
||||
# members_table.columns.show('pk')
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
# permissions = {
|
||||
# 'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
# 'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
# }
|
||||
|
||||
return render(request, 'ipam/vlan_members.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
# 'permissions': permissions,
|
||||
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
})
|
||||
|
||||
|
||||
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vlan'
|
||||
model = VLAN
|
||||
|
@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.3.1'
|
||||
VERSION = '2.3.2'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@ -133,7 +133,6 @@ INSTALLED_APPS = (
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'rest_framework_swagger',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
'dcim',
|
||||
@ -144,6 +143,7 @@ INSTALLED_APPS = (
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
'drf_yasg',
|
||||
)
|
||||
|
||||
# Middleware
|
||||
@ -246,6 +246,32 @@ REST_FRAMEWORK = {
|
||||
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
|
||||
}
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||
'drf_yasg.inspectors.RelatedFieldInspector',
|
||||
'drf_yasg.inspectors.ChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.FileFieldInspector',
|
||||
'drf_yasg.inspectors.DictFieldInspector',
|
||||
'drf_yasg.inspectors.SimpleFieldInspector',
|
||||
'drf_yasg.inspectors.StringDefaultFieldInspector',
|
||||
],
|
||||
'DEFAULT_FILTER_INSPECTORS': [
|
||||
'utilities.custom_inspectors.IdInFilterInspector',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
],
|
||||
'DEFAULT_PAGINATOR_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullablePaginatorInspector',
|
||||
'drf_yasg.inspectors.DjangoRestResponsePagination',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Django debug toolbar
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
|
@ -4,12 +4,24 @@ from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.static import serve
|
||||
from rest_framework_swagger.views import get_swagger_view
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
from netbox.views import APIRootView, HomeView, SearchView
|
||||
from users.views import LoginView, LogoutView
|
||||
|
||||
swagger_view = get_swagger_view(title='NetBox API')
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="NetBox API",
|
||||
default_version='v2',
|
||||
description="API to access NetBox",
|
||||
terms_of_service="https://github.com/digitalocean/netbox",
|
||||
contact=openapi.Contact(email="netbox@digitalocean.com"),
|
||||
license=openapi.License(name="Apache v2 License"),
|
||||
),
|
||||
validators=['flex', 'ssv'],
|
||||
public=True,
|
||||
)
|
||||
|
||||
_patterns = [
|
||||
|
||||
@ -40,7 +52,9 @@ _patterns = [
|
||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||
url(r'^api/virtualization/', include('virtualization.api.urls')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'),
|
||||
url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'),
|
||||
url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
|
@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
|
||||
#
|
||||
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
plaintext = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
|
||||
)
|
||||
plaintext2 = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(SecretForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# A plaintext value is required when creating a new Secret
|
||||
if not self.instance.pk:
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that the provided plaintext values match
|
||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||
raise forms.ValidationError({
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
|
55
netbox/templates/dcim/inc/interface_vlans_table.html
Normal file
55
netbox/templates/dcim/inc/interface_vlans_table.html
Normal file
@ -0,0 +1,55 @@
|
||||
<table class="table panel-body">
|
||||
<tr>
|
||||
<th>VID</th>
|
||||
<th>Name</th>
|
||||
<th>Untagged</th>
|
||||
<th>Tagged</th>
|
||||
</tr>
|
||||
{% with tagged_vlans=obj.tagged_vlans.all %}
|
||||
{% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ obj.untagged_vlan.get_absolute_url }}">{{ obj.untagged_vlan.vid }}</a>
|
||||
</td>
|
||||
<td>{{ obj.untagged_vlan.name }}</td>
|
||||
<td>
|
||||
<input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="checked" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" />
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for vlan in tagged_vlans %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ vlan.get_absolute_url }}">{{ vlan.vid }}</a>
|
||||
</td>
|
||||
<td>{{ vlan.name }}</td>
|
||||
<td>
|
||||
<input type="radio" name="untagged_vlan" value="{{ vlan.pk }}"{% if vlan == obj.untagged_vlan %} checked="checked"{% endif %} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="checked" />
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not obj.untagged_vlan and not tagged_vlans %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center">
|
||||
No VLANs assigned
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td>
|
||||
<a href="#" id="clear_untagged_vlan" class="btn btn-warning btn-xs">Clear</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" id="clear_tagged_vlans" class="btn btn-warning btn-xs">Clear All</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</table>
|
@ -13,16 +13,44 @@
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
</div>
|
||||
{% if obj.mode %}
|
||||
<div class="panel panel-default" id="vlans_panel">
|
||||
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
|
||||
{% include 'dcim/inc/interface_vlans_table.html' %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
|
||||
<i class="glyphicon glyphicon-plus"></i> Add VLANs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
<button type="submit" formaction="?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#clear_untagged_vlan').click(function () {
|
||||
$('input[name="untagged_vlan"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
$('#clear_tagged_vlans').click(function () {
|
||||
$('input[name="tagged_vlans"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
46
netbox/templates/ipam/inc/vlan_header.html
Normal file
46
netbox/templates/ipam/inc/vlan_header.html
Normal file
@ -0,0 +1,46 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
|
||||
{% if vlan.site %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
|
||||
{% endif %}
|
||||
{% if vlan.group %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ vlan }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'ipam:vlan_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if perms.ipam.change_vlan %}
|
||||
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_vlan %}
|
||||
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=vlan %}
|
||||
<ul class="nav nav-tabs" style="margin-bottom: 20px">
|
||||
<li role="presentation"{% if active_tab == 'vlan' %} class="active"{% endif %}><a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a></li>
|
||||
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}><a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a></li>
|
||||
</ul>
|
@ -1,48 +1,7 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
|
||||
{% if vlan.site %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
|
||||
{% endif %}
|
||||
{% if vlan.group %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ vlan }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'ipam:vlan_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if perms.ipam.change_vlan %}
|
||||
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_vlan %}
|
||||
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=vlan %}
|
||||
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
12
netbox/templates/ipam/vlan_members.html
Normal file
12
netbox/templates/ipam/vlan_members.html
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block title %}{{ vlan }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -31,13 +31,15 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3 text-right">
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
53
netbox/templates/virtualization/interface_edit.html
Normal file
53
netbox/templates/virtualization/interface_edit.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% if obj.mode %}
|
||||
<div class="panel panel-default" id="vlans_panel">
|
||||
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
|
||||
{% include 'dcim/inc/interface_vlans_table.html' %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
|
||||
<i class="glyphicon glyphicon-plus"></i> Add VLANs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
<button type="submit" formaction="?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#clear_untagged_vlan').click(function () {
|
||||
$('input[name="untagged_vlan"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
$('#clear_tagged_vlans').click(function () {
|
||||
$('input[name="tagged_vlans"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
76
netbox/utilities/custom_inspectors.py
Normal file
76
netbox/utilities/custom_inspectors.py
Normal file
@ -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
|
@ -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.<br />'\
|
||||
'Example: <code>ge-0/0/[0-23,25,30]</code>'
|
||||
self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
|
||||
'Mixed cases and types within a single range are not supported.<br />' \
|
||||
'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
|
||||
'<li><code>e[0-3][a-d,f]</code></li>' \
|
||||
'<li><code>e[0-3,a-d,f]</code></li></ul>'
|
||||
|
||||
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:
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -1,2 +1,3 @@
|
||||
django-rest-swagger
|
||||
psycopg2
|
||||
pycrypto
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user