mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -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/).
|
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),
|
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
|
### Build Status
|
||||||
|
|
||||||
@ -41,3 +41,4 @@ and run `upgrade.sh`.
|
|||||||
|
|
||||||
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
* [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))
|
* [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 !
|
ProxyPass !
|
||||||
</Location>
|
</Location>
|
||||||
|
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
ProxyPass / http://127.0.0.1:8001/
|
ProxyPass / http://127.0.0.1:8001/
|
||||||
ProxyPassReverse / http://127.0.0.1:8001/
|
ProxyPassReverse / http://127.0.0.1:8001/
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
|||||||
```no-highlight
|
```no-highlight
|
||||||
# a2enmod proxy
|
# a2enmod proxy
|
||||||
# a2enmod proxy_http
|
# a2enmod proxy_http
|
||||||
|
# a2enmod headers
|
||||||
# a2ensite netbox
|
# a2ensite netbox
|
||||||
# service apache2 restart
|
# service apache2 restart
|
||||||
```
|
```
|
||||||
|
@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
|
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
|
||||||
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||||
)
|
)
|
||||||
from .constants import CIRCUIT_STATUS_CHOICES
|
from .constants import CIRCUIT_STATUS_CHOICES
|
||||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
|
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):
|
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Circuit
|
model = Circuit
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug'
|
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(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
|
@ -6,6 +6,9 @@ from django.conf import settings
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||||
from django.shortcuts import get_object_or_404
|
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.decorators import detail_route
|
||||||
from rest_framework.mixins import ListModelMixin
|
from rest_framework.mixins import ListModelMixin
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
|
|||||||
* `peer-interface`: The name of the peer interface
|
* `peer-interface`: The name of the peer interface
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
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):
|
def get_view_name(self):
|
||||||
return "Connected Device Locator"
|
return "Connected Device Locator"
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
|
||||||
def list(self, request):
|
def list(self, request):
|
||||||
|
|
||||||
peer_device_name = request.query_params.get('peer-device')
|
peer_device_name = request.query_params.get(self._device_param.name)
|
||||||
peer_interface_name = request.query_params.get('peer-interface')
|
peer_interface_name = request.query_params.get(self._interface_param.name)
|
||||||
if not peer_device_name or not peer_interface_name:
|
if not peer_device_name or not peer_interface_name:
|
||||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
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.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
|
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
|
||||||
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
|
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||||
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
|
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||||
SmallTextarea, SlugField,
|
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
from .constants import (
|
from .constants import (
|
||||||
@ -37,6 +36,12 @@ from .models import (
|
|||||||
|
|
||||||
DEVICE_BY_PK_RE = '{\d+\}'
|
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):
|
def get_device_by_name_or_pk(name):
|
||||||
"""
|
"""
|
||||||
@ -172,17 +177,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
|
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):
|
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Site
|
model = Site
|
||||||
q = forms.CharField(required=False, label='Search')
|
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(
|
region = FilterTreeNodeMultipleChoiceField(
|
||||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
@ -700,13 +703,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class PlatformCSVForm(forms.ModelForm):
|
class PlatformCSVForm(forms.ModelForm):
|
||||||
slug = SlugField()
|
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:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = Platform.csv_headers
|
fields = Platform.csv_headers
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': 'Platform name',
|
'name': 'Platform name',
|
||||||
'manufacturer': 'Manufacturer name',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1040,13 +1051,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['tenant', 'platform', 'serial']
|
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):
|
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Device
|
model = Device
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -1084,7 +1088,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --',
|
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')
|
mac_address = forms.CharField(required=False, label='MAC address')
|
||||||
has_primary_ip = forms.NullBooleanField(
|
has_primary_ip = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -1648,63 +1657,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
|||||||
# Interfaces
|
# Interfaces
|
||||||
#
|
#
|
||||||
|
|
||||||
class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||||
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 Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
|
||||||
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
|
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'device': forms.HiddenInput(),
|
'device': forms.HiddenInput(),
|
||||||
}
|
}
|
||||||
|
labels = {
|
||||||
|
'mode': '802.1Q Mode',
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(InterfaceForm, self).__init__(*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
|
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
|
def clean(self):
|
||||||
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
|
super(InterfaceForm, self).clean()
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
# Validate VLAN assignments
|
||||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||||
|
|
||||||
def clean_tagged_vlans(self):
|
# Untagged interfaces cannot be assigned tagged VLANs
|
||||||
"""
|
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
|
||||||
Because tagged_vlans is a many-to-many relationship, validation must be done in the form
|
raise forms.ValidationError({
|
||||||
"""
|
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
})
|
||||||
raise forms.ValidationError(
|
|
||||||
"An Access interface cannot have tagged VLANs."
|
# 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']:
|
parent = self.instance.parent
|
||||||
raise forms.ValidationError(
|
if parent is not None:
|
||||||
"Interface mode Tagged All implies all VLANs are tagged. "
|
|
||||||
"Do not select any tagged VLANs."
|
|
||||||
)
|
|
||||||
|
|
||||||
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')
|
name_pattern = ExpandableNameField(label='Name')
|
||||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||||
enabled = forms.BooleanField(required=False)
|
enabled = forms.BooleanField(required=False)
|
||||||
@ -1786,50 +1805,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
|||||||
)
|
)
|
||||||
description = forms.CharField(max_length=100, required=False)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), 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):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -1847,41 +1822,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
|||||||
else:
|
else:
|
||||||
self.fields['lag'].queryset = Interface.objects.none()
|
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
|
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||||
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):
|
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
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')
|
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||||
description = forms.CharField(max_length=100, required=False)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), 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:
|
class Meta:
|
||||||
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
|
nullable_fields = ['lag', 'mtu', 'description', 'mode']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||||
@ -1951,28 +1849,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
|||||||
else:
|
else:
|
||||||
self.fields['lag'].choices = []
|
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):
|
class InterfaceBulkRenameForm(BulkRenameForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
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)
|
"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
|
@property
|
||||||
def parent(self):
|
def parent(self):
|
||||||
return self.device or self.virtual_machine
|
return self.device or self.virtual_machine
|
||||||
|
@ -47,8 +47,13 @@ REGION_ACTIONS = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
RACKGROUP_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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """
|
|||||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
{% 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 = """
|
UTILIZATION_GRAPH = """
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% utilization_graph value %}
|
{% utilization_graph value %}
|
||||||
@ -182,12 +191,21 @@ class SiteTable(BaseTable):
|
|||||||
|
|
||||||
class RackGroupTable(BaseTable):
|
class RackGroupTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn(verbose_name='Name')
|
name = tables.LinkColumn()
|
||||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
site = tables.LinkColumn(
|
||||||
rack_count = tables.Column(verbose_name='Racks')
|
viewname='dcim:site',
|
||||||
slug = tables.Column(verbose_name='Slug')
|
args=[Accessor('site.slug')],
|
||||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
verbose_name='Site'
|
||||||
verbose_name='')
|
)
|
||||||
|
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):
|
class Meta(BaseTable.Meta):
|
||||||
model = RackGroup
|
model = RackGroup
|
||||||
@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable):
|
|||||||
|
|
||||||
class DeviceTypeTable(BaseTable):
|
class DeviceTypeTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
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_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
subdevice_role = tables.TemplateColumn(
|
||||||
instance_count = tables.Column(verbose_name='Instances')
|
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):
|
class Meta(BaseTable.Meta):
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
|
@ -5,7 +5,9 @@ from django.urls import reverse
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
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 (
|
from dcim.models import (
|
||||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||||
@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
|||||||
data = {
|
data = {
|
||||||
'device': self.device.pk,
|
'device': self.device.pk,
|
||||||
'name': 'Test Interface 4',
|
'name': 'Test Interface 4',
|
||||||
|
'mode': IFACE_MODE_TAGGED,
|
||||||
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
|
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
|
||||||
'untagged_vlan': self.vlan3.id
|
'untagged_vlan': self.vlan3.id
|
||||||
}
|
}
|
||||||
@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
|||||||
{
|
{
|
||||||
'device': self.device.pk,
|
'device': self.device.pk,
|
||||||
'name': 'Test Interface 4',
|
'name': 'Test Interface 4',
|
||||||
|
'mode': IFACE_MODE_TAGGED,
|
||||||
'tagged_vlans': [self.vlan1.id],
|
'tagged_vlans': [self.vlan1.id],
|
||||||
'untagged_vlan': self.vlan2.id,
|
'untagged_vlan': self.vlan2.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': self.device.pk,
|
'device': self.device.pk,
|
||||||
'name': 'Test Interface 5',
|
'name': 'Test Interface 5',
|
||||||
|
'mode': IFACE_MODE_TAGGED,
|
||||||
'tagged_vlans': [self.vlan1.id],
|
'tagged_vlans': [self.vlan1.id],
|
||||||
'untagged_vlan': self.vlan2.id,
|
'untagged_vlan': self.vlan2.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'device': self.device.pk,
|
'device': self.device.pk,
|
||||||
'name': 'Test Interface 6',
|
'name': 'Test Interface 6',
|
||||||
|
'mode': IFACE_MODE_TAGGED,
|
||||||
'tagged_vlans': [self.vlan1.id],
|
'tagged_vlans': [self.vlan1.id],
|
||||||
'untagged_vlan': self.vlan2.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'^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'^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+)/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/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||||
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
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):
|
def get(self, request, pk):
|
||||||
|
|
||||||
device = get_object_or_404(Device, pk=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
|
device.device_type.interface_ordering
|
||||||
).connectable().filter(
|
).connectable().select_related(
|
||||||
device=device
|
|
||||||
).select_related(
|
|
||||||
'connected_as_a', 'connected_as_b'
|
'connected_as_a', 'connected_as_b'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1645,6 +1643,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
|||||||
template_name = 'dcim/interface_edit.html'
|
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):
|
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||||
permission_required = 'dcim.delete_interface'
|
permission_required = 'dcim.delete_interface'
|
||||||
model = Interface
|
model = Interface
|
||||||
@ -2226,7 +2230,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
|||||||
device = member_select_form.cleaned_data['device']
|
device = member_select_form.cleaned_data['device']
|
||||||
device.virtual_chassis = virtual_chassis
|
device.virtual_chassis = virtual_chassis
|
||||||
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
|
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():
|
if membership_form.is_valid():
|
||||||
|
|
||||||
@ -2242,7 +2246,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
membership_form = forms.DeviceVCMembershipForm(request.POST)
|
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
|
||||||
|
|
||||||
return render(request, 'dcim/virtualchassis_add_member.html', {
|
return render(request, 'dcim/virtualchassis_add_member.html', {
|
||||||
'virtual_chassis': virtual_chassis,
|
'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
|
Convert a string into the object it represents depending on the type of field
|
||||||
"""
|
"""
|
||||||
if serialized_value is '':
|
if serialized_value == '':
|
||||||
return None
|
return None
|
||||||
if self.type == CF_TYPE_INTEGER:
|
if self.type == CF_TYPE_INTEGER:
|
||||||
return int(serialized_value)
|
return int(serialized_value)
|
||||||
|
@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
|
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
|
||||||
add_blank_choice,
|
SlugField, add_blank_choice,
|
||||||
)
|
)
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
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']
|
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):
|
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = Prefix
|
model = Prefix
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --'
|
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(
|
site = FilterChoiceField(
|
||||||
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
|||||||
address = forms.CharField(label='IP Address')
|
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):
|
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --'
|
null_label='-- None --'
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
status = AnnotatedMultipleChoiceField(
|
||||||
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
|
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']
|
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):
|
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = VLAN
|
model = VLAN
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --'
|
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(
|
role = FilterChoiceField(
|
||||||
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
|
@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
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])
|
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||||
else:
|
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
|
prefix_size = self.prefix.size
|
||||||
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||||
prefix_size -= 2
|
prefix_size -= 2
|
||||||
@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return STATUS_CHOICE_CLASSES[self.status]
|
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
|
@python_2_unicode_compatible
|
||||||
class Service(CreatedUpdatedModel):
|
class Service(CreatedUpdatedModel):
|
||||||
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
|
|
||||||
|
from dcim.models import Interface
|
||||||
from tenancy.tables import COL_TENANT
|
from tenancy.tables import COL_TENANT
|
||||||
from utilities.tables import BaseTable, ToggleColumn
|
from utilities.tables import BaseTable, ToggleColumn
|
||||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||||
@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
|
|||||||
{% endif %}
|
{% 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 = """
|
TENANT_LINK = """
|
||||||
{% if record.tenant %}
|
{% if record.tenant %}
|
||||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
<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):
|
class Meta(VLANTable.Meta):
|
||||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
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/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||||
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
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+)/$', 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+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||||
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
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):
|
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'ipam.add_vlan'
|
permission_required = 'ipam.add_vlan'
|
||||||
model = VLAN
|
model = VLAN
|
||||||
|
@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
|||||||
DeprecationWarning
|
DeprecationWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
VERSION = '2.3.1'
|
VERSION = '2.3.2'
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
@ -133,7 +133,6 @@ INSTALLED_APPS = (
|
|||||||
'django_tables2',
|
'django_tables2',
|
||||||
'mptt',
|
'mptt',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework_swagger',
|
|
||||||
'timezone_field',
|
'timezone_field',
|
||||||
'circuits',
|
'circuits',
|
||||||
'dcim',
|
'dcim',
|
||||||
@ -144,6 +143,7 @@ INSTALLED_APPS = (
|
|||||||
'users',
|
'users',
|
||||||
'utilities',
|
'utilities',
|
||||||
'virtualization',
|
'virtualization',
|
||||||
|
'drf_yasg',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Middleware
|
# Middleware
|
||||||
@ -246,6 +246,32 @@ REST_FRAMEWORK = {
|
|||||||
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
|
'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
|
# Django debug toolbar
|
||||||
INTERNAL_IPS = (
|
INTERNAL_IPS = (
|
||||||
'127.0.0.1',
|
'127.0.0.1',
|
||||||
|
@ -4,12 +4,24 @@ from django.conf import settings
|
|||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.views.static import serve
|
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 netbox.views import APIRootView, HomeView, SearchView
|
||||||
from users.views import LoginView, LogoutView
|
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 = [
|
_patterns = [
|
||||||
|
|
||||||
@ -40,7 +52,9 @@ _patterns = [
|
|||||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||||
url(r'^api/virtualization/', include('virtualization.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
|
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||||
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||||
|
@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
plaintext = forms.CharField(
|
||||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
|
max_length=65535,
|
||||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
required=False,
|
||||||
widget=forms.PasswordInput())
|
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:
|
class Meta:
|
||||||
model = Secret
|
model = Secret
|
||||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
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):
|
def clean(self):
|
||||||
|
|
||||||
|
# Verify that the provided plaintext values match
|
||||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError({
|
||||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
'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.mtu %}
|
||||||
{% render_field form.mgmt_only %}
|
{% render_field form.mgmt_only %}
|
||||||
{% render_field form.description %}
|
{% 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.mode %}
|
||||||
{% render_field form.site %}
|
|
||||||
{% render_field form.vlan_group %}
|
|
||||||
{% render_field form.untagged_vlan %}
|
|
||||||
{% render_field form.tagged_vlans %}
|
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% 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' %}
|
{% extends '_base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
|
||||||
<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 %}
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="panel panel-default">
|
<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>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-md-offset-3 text-right">
|
<div class="col-md-6 col-md-offset-3 text-right">
|
||||||
{% if obj.pk %}
|
{% block buttons %}
|
||||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
{% if obj.pk %}
|
||||||
{% else %}
|
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
{% else %}
|
||||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||||
{% endif %}
|
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
{% endif %}
|
||||||
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||||
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db.models import Count
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from mptt.forms import TreeNodeMultipleChoiceField
|
from mptt.forms import TreeNodeMultipleChoiceField
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ COLOR_CHOICES = (
|
|||||||
('111111', 'Black'),
|
('111111', 'Black'),
|
||||||
)
|
)
|
||||||
NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
|
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})\]'
|
IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
|
||||||
IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
|
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)
|
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):
|
def expand_ipaddress_pattern(string, family):
|
||||||
"""
|
"""
|
||||||
Expand an IP address pattern into a list of strings. Examples:
|
Expand an IP address pattern into a list of strings. Examples:
|
||||||
@ -305,12 +346,15 @@ class ExpandableNameField(forms.CharField):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(ExpandableNameField, self).__init__(*args, **kwargs)
|
super(ExpandableNameField, self).__init__(*args, **kwargs)
|
||||||
if not self.help_text:
|
if not self.help_text:
|
||||||
self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
|
self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
|
||||||
'Example: <code>ge-0/0/[0-23,25,30]</code>'
|
'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):
|
def to_python(self, value):
|
||||||
if re.search(NUMERIC_EXPANSION_PATTERN, value):
|
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
|
||||||
return list(expand_numeric_pattern(value))
|
return list(expand_alphanumeric_pattern(value))
|
||||||
return [value]
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
@ -450,6 +494,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
|
|||||||
pass
|
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):
|
class LaxURLField(forms.URLField):
|
||||||
"""
|
"""
|
||||||
Modifies Django's built-in URLField in two ways:
|
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 django.db.models import Count
|
||||||
from mptt.forms import TreeNodeChoiceField
|
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.formfields import MACAddressFormField
|
||||||
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
|
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
|
||||||
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
|
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
|
||||||
@ -13,9 +14,9 @@ from ipam.models import IPAddress
|
|||||||
from tenancy.forms import TenancyForm
|
from tenancy.forms import TenancyForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||||
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
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 .constants import VM_STATUS_CHOICES
|
||||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
|||||||
nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
|
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):
|
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||||
model = VirtualMachine
|
model = VirtualMachine
|
||||||
q = forms.CharField(required=False, label='Search')
|
q = forms.CharField(required=False, label='Search')
|
||||||
@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
null_label='-- None --'
|
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(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
|
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
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 = {
|
widgets = {
|
||||||
'virtual_machine': forms.HiddenInput(),
|
'virtual_machine': forms.HiddenInput(),
|
||||||
'form_factor': 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):
|
class InterfaceCreateForm(ComponentForm):
|
||||||
|
@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
def site(self):
|
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
|
return self.cluster.site
|
||||||
|
@ -329,6 +329,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
|||||||
permission_required = 'dcim.change_interface'
|
permission_required = 'dcim.change_interface'
|
||||||
model = Interface
|
model = Interface
|
||||||
model_form = forms.InterfaceForm
|
model_form = forms.InterfaceForm
|
||||||
|
template_name = 'virtualization/interface_edit.html'
|
||||||
|
|
||||||
|
|
||||||
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
|
django-rest-swagger
|
||||||
psycopg2
|
psycopg2
|
||||||
pycrypto
|
pycrypto
|
||||||
|
@ -3,10 +3,10 @@ django-cors-headers>=2.1.0
|
|||||||
django-debug-toolbar>=1.9.0
|
django-debug-toolbar>=1.9.0
|
||||||
django-filter>=1.1.0
|
django-filter>=1.1.0
|
||||||
django-mptt>=0.9.0
|
django-mptt>=0.9.0
|
||||||
django-rest-swagger>=2.1.0
|
|
||||||
django-tables2>=1.19.0
|
django-tables2>=1.19.0
|
||||||
django-timezone-field>=2.0
|
django-timezone-field>=2.0
|
||||||
djangorestframework>=3.7.7
|
djangorestframework>=3.7.7
|
||||||
|
drf-yasg[validation]>=1.4.4
|
||||||
graphviz>=0.8.2
|
graphviz>=0.8.2
|
||||||
Markdown>=2.6.11
|
Markdown>=2.6.11
|
||||||
natsort>=5.2.0
|
natsort>=5.2.0
|
||||||
|
Loading…
Reference in New Issue
Block a user