mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 23:57:46 -06:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328958876a | ||
|
|
a7389de109 | ||
|
|
b911ab01d2 | ||
|
|
9153c71cbf | ||
|
|
b44aa9d32e | ||
|
|
bcb1d9af0b | ||
|
|
ef84889a57 | ||
|
|
81c027e7cf | ||
|
|
fd62a248ee | ||
|
|
2c8bea1b59 | ||
|
|
07364abf9e | ||
|
|
20cb13e1bb | ||
|
|
3f3b385de7 | ||
|
|
94b12e506e | ||
|
|
4ec6e52e73 | ||
|
|
88adc5ca86 | ||
|
|
68f73c7f94 | ||
|
|
223c95adbc | ||
|
|
3aaca1ca02 | ||
|
|
6a4d17b8a5 | ||
|
|
720c5fabaf | ||
|
|
1c5239a4d0 | ||
|
|
05b5609d86 | ||
|
|
7e92aeb7ac | ||
|
|
6e2eb15a80 | ||
|
|
0b825ac3d0 | ||
|
|
b5f1d74d6f | ||
|
|
e071b7dfd5 | ||
|
|
53e4e74930 | ||
|
|
b83de7eb11 | ||
|
|
38a208242b | ||
|
|
4acd8e180d | ||
|
|
debc8521a5 | ||
|
|
8bd268d81c | ||
|
|
ae6848b194 | ||
|
|
b22744b031 | ||
|
|
a75d7079df | ||
|
|
aa8442a345 | ||
|
|
70625a5cb0 | ||
|
|
7c043d9b4f | ||
|
|
546f17ab50 | ||
|
|
1c9986efc4 | ||
|
|
8ae13e29f5 | ||
|
|
f5bb072f28 | ||
|
|
37eef0ba6d | ||
|
|
603b80db1b | ||
|
|
c823660a8f | ||
|
|
ec4d28ac6c | ||
|
|
0c5ad85b35 | ||
|
|
bdecf7a3e3 | ||
|
|
6b62720daf | ||
|
|
d48c450018 | ||
|
|
078404fb59 | ||
|
|
4bb526896f | ||
|
|
0476006ef2 | ||
|
|
19831f0177 | ||
|
|
fc9871fba3 | ||
|
|
b34f4f8e43 | ||
|
|
0357d8522c | ||
|
|
08d06bd781 | ||
|
|
01a97add2a | ||
|
|
3cb351dceb | ||
|
|
9e11591b3b | ||
|
|
e4c1cece75 | ||
|
|
6881a98048 | ||
|
|
36de9f10d6 | ||
|
|
1cc135f01f | ||
|
|
079c8894fa | ||
|
|
e18b5f5fd4 | ||
|
|
57973f62c5 | ||
|
|
e57b8aa26f | ||
|
|
3d023126ba | ||
|
|
53f58d4496 | ||
|
|
1a6ee237f6 | ||
|
|
33a99441a4 | ||
|
|
3df7e283e3 | ||
|
|
b295849f53 | ||
|
|
c107f35118 | ||
|
|
3d91153275 |
@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
|
||||
@@ -41,3 +41,4 @@ and run `upgrade.sh`.
|
||||
|
||||
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
|
||||
@@ -21,6 +21,12 @@ Copy the 'configuration.py' you created when first installing to the new version
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||
|
||||
```no-highlight
|
||||
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```no-highlight
|
||||
|
||||
@@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
||||
ProxyPass !
|
||||
</Location>
|
||||
|
||||
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||
ProxyPass / http://127.0.0.1:8001/
|
||||
ProxyPassReverse / http://127.0.0.1:8001/
|
||||
</VirtualHost>
|
||||
@@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
||||
```no-highlight
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2enmod headers
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
|
||||
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
|
||||
ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
)
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
|
||||
|
||||
|
||||
def circuit_status_choices():
|
||||
status_counts = {}
|
||||
for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES]
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
annotate=Circuit.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug',
|
||||
|
||||
@@ -731,15 +731,20 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
|
||||
# VirtualMachine, or are global.
|
||||
parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
|
||||
# All associated VLANs be global or assigned to the parent device's site.
|
||||
device = self.instance.device if self.instance else data.get('device')
|
||||
untagged_vlan = data.get('untagged_vlan')
|
||||
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
|
||||
"global.".format(untagged_vlan)
|
||||
})
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [parent, None]:
|
||||
raise serializers.ValidationError(
|
||||
"Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
|
||||
"global".format(vlan)
|
||||
)
|
||||
if vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||
"be global.".format(vlan)
|
||||
})
|
||||
|
||||
return super(WritableInterfaceSerializer, self).validate(data)
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.openapi import Parameter
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.response import Response
|
||||
@@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
* `peer-interface`: The name of the peer interface
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
_device_param = Parameter('peer-device', 'query',
|
||||
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
|
||||
_interface_param = Parameter('peer-interface', 'query',
|
||||
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
|
||||
|
||||
def get_view_name(self):
|
||||
return "Connected Device Locator"
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
|
||||
def list(self, request):
|
||||
|
||||
peer_device_name = request.query_params.get('peer-device')
|
||||
peer_interface_name = request.query_params.get('peer-interface')
|
||||
peer_device_name = request.query_params.get(self._device_param.name)
|
||||
peer_interface_name = request.query_params.get(self._interface_param.name)
|
||||
if not peer_device_name or not peer_interface_name:
|
||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
|
||||
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
|
||||
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
|
||||
SmallTextarea, SlugField,
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
@@ -37,6 +36,12 @@ from .models import (
|
||||
|
||||
DEVICE_BY_PK_RE = '{\d+\}'
|
||||
|
||||
INTERFACE_MODE_HELP_TEXT = """
|
||||
Access: One untagged VLAN<br />
|
||||
Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
|
||||
Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
|
||||
"""
|
||||
|
||||
|
||||
def get_device_by_name_or_pk(name):
|
||||
"""
|
||||
@@ -107,9 +112,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
|
||||
'comments',
|
||||
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -119,6 +123,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
'name': "Full name of the site",
|
||||
'facility': "Data center provider and facility (e.g. Equinix NY7)",
|
||||
'asn': "BGP autonomous system number",
|
||||
'time_zone': "Local time zone",
|
||||
'description': "Short description (will appear in sites list)",
|
||||
'physical_address': "Physical location of the building (e.g. for GPS)",
|
||||
'shipping_address': "If different from the physical address"
|
||||
}
|
||||
@@ -126,7 +132,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
class SiteCSVForm(forms.ModelForm):
|
||||
status = CSVChoiceField(
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
@@ -172,17 +178,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
|
||||
|
||||
|
||||
def site_status_choices():
|
||||
status_counts = {}
|
||||
for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
annotate=Site.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||
to_field_name='slug',
|
||||
@@ -700,13 +704,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class PlatformCSVForm(forms.ModelForm):
|
||||
slug = SlugField()
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Manufacturer name',
|
||||
error_messages={
|
||||
'invalid_choice': 'Manufacturer not found.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = Platform.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Platform name',
|
||||
'manufacturer': 'Manufacturer name',
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,13 +1052,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'platform', 'serial']
|
||||
|
||||
|
||||
def device_status_choices():
|
||||
status_counts = {}
|
||||
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -1084,7 +1089,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
annotate=Device.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
mac_address = forms.CharField(required=False, label='MAC address')
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -1648,61 +1658,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
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 InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
labels = {
|
||||
'mode': '802.1Q Mode',
|
||||
}
|
||||
help_texts = {
|
||||
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceForm, self).__init__(*args, **kwargs)
|
||||
@@ -1719,58 +1691,108 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
||||
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if device and device.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
def clean(self):
|
||||
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
elif self.initial.get('untagged_vlan'):
|
||||
filter_dict = {
|
||||
'group_id': self.instance.untagged_vlan.group,
|
||||
'site_id': self.instance.untagged_vlan.site,
|
||||
}
|
||||
elif self.initial.get('tagged_vlans'):
|
||||
filter_dict = {
|
||||
'group_id': self.instance.tagged_vlans.first().group,
|
||||
'site_id': self.instance.tagged_vlans.first().site,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
super(InterfaceForm, self).clean()
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
# Validate VLAN assignments
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
|
||||
def clean_tagged_vlans(self):
|
||||
"""
|
||||
Because tagged_vlans is a many-to-many relationship, validation must be done in the form
|
||||
"""
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError(
|
||||
"An Access interface cannot have tagged VLANs."
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
|
||||
raise forms.ValidationError({
|
||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||
})
|
||||
|
||||
# Remove all tagged VLAN assignments from "tagged all" interfaces
|
||||
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
|
||||
|
||||
class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
|
||||
vlans = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='VLANs',
|
||||
widget=forms.SelectMultiple(attrs={'size': 20})
|
||||
)
|
||||
tagged = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.mode == IFACE_MODE_ACCESS:
|
||||
self.initial['tagged'] = False
|
||||
|
||||
# Find all VLANs already assigned to the interface for exclusion from the list
|
||||
assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
|
||||
if self.instance.untagged_vlan is not None:
|
||||
assigned_vlans.append(self.instance.untagged_vlan.pk)
|
||||
|
||||
# Compile VLAN choices
|
||||
vlan_choices = []
|
||||
|
||||
# Add global VLANs
|
||||
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
|
||||
)
|
||||
|
||||
# Add grouped global VLANs
|
||||
for group in VLANGroup.objects.filter(site=None):
|
||||
global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append(
|
||||
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
|
||||
)
|
||||
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError(
|
||||
"Interface mode Tagged All implies all VLANs are tagged. "
|
||||
"Do not select any tagged VLANs."
|
||||
)
|
||||
parent = self.instance.parent
|
||||
if parent is not None:
|
||||
|
||||
return self.cleaned_data['tagged_vlans']
|
||||
# Add site VLANs
|
||||
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
|
||||
|
||||
# Add grouped site VLANs
|
||||
for group in VLANGroup.objects.filter(site=parent.site):
|
||||
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
|
||||
vlan_choices.append((
|
||||
'{} / {}'.format(group.site.name, group.name),
|
||||
[(vlan.pk, vlan) for vlan in site_group_vlans]
|
||||
))
|
||||
|
||||
self.fields['vlans'].choices = vlan_choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(InterfaceAssignVLANsForm, self).clean()
|
||||
|
||||
# Only untagged VLANs permitted on an access interface
|
||||
if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
|
||||
|
||||
# 'tagged' is required if more than one VLAN is selected
|
||||
if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
|
||||
raise forms.ValidationError("Only one untagged VLAN may be selected.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
if self.cleaned_data['tagged']:
|
||||
for vlan in self.cleaned_data['vlans']:
|
||||
self.instance.tagged_vlans.add(vlan)
|
||||
else:
|
||||
self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
|
||||
|
||||
return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
class InterfaceCreateForm(ComponentForm, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
@@ -1784,50 +1806,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1845,43 +1823,9 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if self.parent is not None and self.parent.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
elif self.initial.get('untagged_vlan'):
|
||||
filter_dict = {
|
||||
'group_id': self.untagged_vlan.group,
|
||||
'site_id': self.untagged_vlan.site,
|
||||
}
|
||||
elif self.initial.get('tagged_vlans'):
|
||||
filter_dict = {
|
||||
'group_id': self.tagged_vlans.first().group,
|
||||
'site_id': self.tagged_vlans.first().site,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||
@@ -1889,69 +1833,15 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
|
||||
nullable_fields = ['lag', 'mtu', 'description', 'mode']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||
device = None
|
||||
if self.initial.get('device'):
|
||||
try:
|
||||
device = Device.objects.get(pk=self.initial.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
device = Device.objects.get(pk=self.data.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
device = self.parent_obj
|
||||
if device is not None:
|
||||
interface_ordering = device.device_type.interface_ordering
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||
@@ -1960,28 +1850,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if device and device.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
|
||||
|
||||
class InterfaceBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
@@ -2067,7 +1935,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
||||
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize interface A choices
|
||||
device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
|
||||
device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
self.fields['interface_a'].choices = [
|
||||
@@ -2076,9 +1944,11 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
||||
|
||||
# Mark connected interfaces as disabled
|
||||
if self.data.get('device_b'):
|
||||
self.fields['interface_b'].choices = [
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
|
||||
]
|
||||
self.fields['interface_b'].choices = []
|
||||
for iface in self.fields['interface_b'].queryset:
|
||||
self.fields['interface_b'].choices.append(
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected})
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||
@@ -2298,11 +2168,12 @@ class BaseVCMemberFormSet(forms.BaseModelFormSet):
|
||||
# Check for duplicate VC position values
|
||||
vc_position_list = []
|
||||
for form in self.forms:
|
||||
vc_position = form.cleaned_data['vc_position']
|
||||
if vc_position in vc_position_list:
|
||||
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
form.add_error('vc_position', error_msg)
|
||||
vc_position_list.append(vc_position)
|
||||
vc_position = form.cleaned_data.get('vc_position')
|
||||
if vc_position:
|
||||
if vc_position in vc_position_list:
|
||||
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
form.add_error('vc_position', error_msg)
|
||||
vc_position_list.append(vc_position)
|
||||
|
||||
|
||||
class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
@@ -1455,6 +1455,18 @@ class Interface(models.Model):
|
||||
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
if self.mode is None:
|
||||
self.untagged_vlan = None
|
||||
|
||||
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
|
||||
if self.pk and self.mode is not IFACE_MODE_TAGGED:
|
||||
self.tagged_vlans.clear()
|
||||
|
||||
return super(Interface, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
|
||||
@@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet):
|
||||
}[method]
|
||||
|
||||
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
|
||||
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
|
||||
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)"
|
||||
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)"
|
||||
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)"
|
||||
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)"
|
||||
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
|
||||
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
|
||||
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)"
|
||||
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)"
|
||||
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)"
|
||||
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)"
|
||||
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)"
|
||||
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
|
||||
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)"
|
||||
|
||||
fields = {
|
||||
'_type': RawSQL(TYPE_RE.format(sql_col), []),
|
||||
|
||||
@@ -47,8 +47,13 @@ REGION_ACTIONS = """
|
||||
"""
|
||||
|
||||
RACKGROUP_ACTIONS = """
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackgroup %}
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """
|
||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
DEVICETYPE_INSTANCES_TEMPLATE = """
|
||||
<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
@@ -182,12 +191,21 @@ class SiteTable(BaseTable):
|
||||
|
||||
class RackGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')],
|
||||
verbose_name='Site'
|
||||
)
|
||||
rack_count = tables.Column(
|
||||
verbose_name='Racks'
|
||||
)
|
||||
slug = tables.Column()
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKGROUP_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
@@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable):
|
||||
|
||||
class DeviceTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
model = tables.LinkColumn(
|
||||
viewname='dcim:devicetype',
|
||||
args=[Accessor('pk')],
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
||||
instance_count = tables.Column(verbose_name='Instances')
|
||||
subdevice_role = tables.TemplateColumn(
|
||||
template_code=SUBDEVICE_ROLE_TEMPLATE,
|
||||
verbose_name='Subdevice Role'
|
||||
)
|
||||
instance_count = tables.TemplateColumn(
|
||||
template_code=DEVICETYPE_INSTANCES_TEMPLATE,
|
||||
verbose_name='Instances'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
|
||||
@@ -5,13 +5,16 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
|
||||
from dcim.constants import (
|
||||
IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
|
||||
)
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
from ipam.models import VLAN
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
@@ -2258,6 +2261,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
||||
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
|
||||
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
|
||||
|
||||
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
|
||||
self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
|
||||
self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
|
||||
|
||||
def test_get_interface(self):
|
||||
|
||||
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
|
||||
@@ -2309,6 +2316,27 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(interface4.device_id, data['device'])
|
||||
self.assertEqual(interface4.name, data['name'])
|
||||
|
||||
def test_create_interface_with_802_1q(self):
|
||||
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
|
||||
'untagged_vlan': self.vlan3.id
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:interface-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Interface.objects.count(), 4)
|
||||
interface5 = Interface.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(interface5.device_id, data['device'])
|
||||
self.assertEqual(interface5.name, data['name'])
|
||||
self.assertEqual(interface5.tagged_vlans.count(), 2)
|
||||
self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan'])
|
||||
|
||||
def test_create_interface_bulk(self):
|
||||
|
||||
data = [
|
||||
@@ -2335,6 +2363,47 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_create_interface_802_1q_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 5',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 6',
|
||||
'mode': IFACE_MODE_TAGGED,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('dcim-api:interface-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Interface.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
self.assertEqual(len(response.data[0]['tagged_vlans']), 1)
|
||||
self.assertEqual(len(response.data[1]['tagged_vlans']), 1)
|
||||
self.assertEqual(len(response.data[2]['tagged_vlans']), 1)
|
||||
self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id)
|
||||
self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id)
|
||||
self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id)
|
||||
|
||||
def test_update_interface(self):
|
||||
|
||||
lag_interface = Interface.objects.create(
|
||||
|
||||
@@ -185,6 +185,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.forms import ModelChoiceField, ModelForm, modelformset_factory
|
||||
from django.forms import modelformset_factory
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
@@ -41,19 +41,21 @@ class BulkRenameView(View):
|
||||
"""
|
||||
An extendable view for renaming device components in bulk.
|
||||
"""
|
||||
model = None
|
||||
queryset = None
|
||||
form = None
|
||||
template_name = 'dcim/bulk_rename.html'
|
||||
|
||||
def post(self, request):
|
||||
|
||||
model = self.queryset.model
|
||||
|
||||
return_url = request.GET.get('return_url')
|
||||
if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
|
||||
return_url = 'home'
|
||||
|
||||
if '_preview' in request.POST or '_apply' in request.POST:
|
||||
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
|
||||
selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
|
||||
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
|
||||
|
||||
if form.is_valid():
|
||||
for obj in selected_objects:
|
||||
@@ -65,17 +67,17 @@ class BulkRenameView(View):
|
||||
obj.save()
|
||||
messages.success(request, "Renamed {} {}".format(
|
||||
len(selected_objects),
|
||||
self.model._meta.verbose_name_plural
|
||||
model._meta.verbose_name_plural
|
||||
))
|
||||
return redirect(return_url)
|
||||
|
||||
else:
|
||||
form = self.form(initial={'pk': request.POST.getlist('pk')})
|
||||
selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
|
||||
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'obj_type_plural': self.model._meta.verbose_name_plural,
|
||||
'obj_type_plural': model._meta.verbose_name_plural,
|
||||
'selected_objects': selected_objects,
|
||||
'return_url': return_url,
|
||||
})
|
||||
@@ -962,11 +964,9 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
interfaces = Interface.objects.order_naturally(
|
||||
interfaces = device.vc_interfaces.order_naturally(
|
||||
device.device_type.interface_ordering
|
||||
).connectable().filter(
|
||||
device=device
|
||||
).select_related(
|
||||
).connectable().select_related(
|
||||
'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
|
||||
@@ -1318,7 +1318,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_consoleserverport'
|
||||
model = ConsoleServerPort
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
form = forms.ConsoleServerPortBulkRenameForm
|
||||
|
||||
|
||||
@@ -1602,7 +1602,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_poweroutlet'
|
||||
model = PowerOutlet
|
||||
queryset = PowerOutlet.objects.all()
|
||||
form = forms.PowerOutletBulkRenameForm
|
||||
|
||||
|
||||
@@ -1645,6 +1645,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
template_name = 'dcim/interface_edit.html'
|
||||
|
||||
|
||||
class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
model_form = forms.InterfaceAssignVLANsForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
@@ -1672,7 +1678,7 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
|
||||
class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
queryset = Interface.objects.order_naturally()
|
||||
form = forms.InterfaceBulkRenameForm
|
||||
|
||||
|
||||
@@ -1779,7 +1785,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
|
||||
|
||||
class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_devicebay'
|
||||
model = DeviceBay
|
||||
queryset = DeviceBay.objects.all()
|
||||
form = forms.DeviceBayBulkRenameForm
|
||||
|
||||
|
||||
@@ -2082,14 +2088,13 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
|
||||
# Get the list of devices being added to a VirtualChassis
|
||||
pk_form = forms.DeviceSelectionForm(request.POST)
|
||||
pk_form.full_clean()
|
||||
if not pk_form.cleaned_data.get('pk'):
|
||||
messages.warning(request, "No devices were selected.")
|
||||
return redirect('dcim:device_list')
|
||||
device_queryset = Device.objects.filter(
|
||||
pk__in=pk_form.cleaned_data.get('pk')
|
||||
).select_related('rack').order_by('vc_position')
|
||||
|
||||
if not device_queryset:
|
||||
messages.warning(request, "No devices were selected.")
|
||||
return redirect('dcim:device_list')
|
||||
|
||||
VCMemberFormSet = modelformset_factory(
|
||||
model=Device,
|
||||
formset=forms.BaseVCMemberFormSet,
|
||||
@@ -2227,7 +2232,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
||||
device = member_select_form.cleaned_data['device']
|
||||
device.virtual_chassis = virtual_chassis
|
||||
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
|
||||
membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device)
|
||||
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
|
||||
|
||||
if membership_form.is_valid():
|
||||
|
||||
@@ -2243,7 +2248,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
|
||||
|
||||
else:
|
||||
|
||||
membership_form = forms.DeviceVCMembershipForm(request.POST)
|
||||
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
|
||||
|
||||
return render(request, 'dcim/virtualchassis_add_member.html', {
|
||||
'virtual_chassis': virtual_chassis,
|
||||
|
||||
@@ -43,11 +43,18 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
return queryset.none()
|
||||
|
||||
# Apply the assigned filter logic (exact or loose)
|
||||
queryset = queryset.filter(custom_field_values__field__name=self.name)
|
||||
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
|
||||
return queryset.filter(custom_field_values__serialized_value=value)
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value=value
|
||||
)
|
||||
else:
|
||||
return queryset.filter(custom_field_values__serialized_value__icontains=value)
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value__icontains=value
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
|
||||
@@ -127,7 +127,7 @@ class CustomField(models.Model):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value is '':
|
||||
if serialized_value == '':
|
||||
return None
|
||||
if self.type == CF_TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
|
||||
@@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
|
||||
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
|
||||
add_blank_choice,
|
||||
AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
|
||||
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
|
||||
SlugField, add_blank_choice,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
|
||||
@@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def prefix_status_choices():
|
||||
status_counts = {}
|
||||
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=PREFIX_STATUS_CHOICES,
|
||||
annotate=Prefix.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
|
||||
to_field_name='slug',
|
||||
@@ -510,7 +508,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
|
||||
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
||||
|
||||
# Assign this IPAddress as the primary for the associated Device.
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4:
|
||||
@@ -518,14 +516,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
else:
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
# Clear assignment as primary for device if set.
|
||||
elif self.cleaned_data['interface']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4 and parent.primary_ip4 == self:
|
||||
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
|
||||
parent.primary_ip4 = None
|
||||
parent.save()
|
||||
elif ipaddress.address.version == 6 and parent.primary_ip6 == self:
|
||||
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
|
||||
parent.primary_ip6 = None
|
||||
parent.save()
|
||||
|
||||
@@ -688,20 +684,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
address = forms.CharField(label='IP Address')
|
||||
|
||||
|
||||
def ipaddress_status_choices():
|
||||
status_counts = {}
|
||||
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
|
||||
|
||||
|
||||
def ipaddress_role_choices():
|
||||
role_counts = {}
|
||||
for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
|
||||
role_counts[role['role']] = role['count']
|
||||
return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = IPAddress
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -721,8 +703,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
||||
role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_STATUS_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
role = AnnotatedMultipleChoiceField(
|
||||
choices=IPADDRESS_ROLE_CHOICES,
|
||||
annotate=IPAddress.objects.all(),
|
||||
annotate_field='role',
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -878,13 +870,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
status_counts = {}
|
||||
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -903,7 +888,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=VLAN_STATUS_CHOICES,
|
||||
annotate=VLAN.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=Role.objects.annotate(filter_count=Count('vlans')),
|
||||
to_field_name='slug',
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
@@ -365,7 +366,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
else:
|
||||
child_count = self.get_child_ips().count()
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||
prefix_size = self.prefix.size
|
||||
if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
@@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
def get_members(self):
|
||||
# Return all interfaces assigned to this VLAN
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Service(CreatedUpdatedModel):
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
@@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_UNTAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
<i class="glyphicon glyphicon-ok">
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_ACTIONS = """
|
||||
{% if perms.dcim.change_interface %}
|
||||
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TENANT_LINK = """
|
||||
{% if record.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
||||
@@ -316,7 +329,7 @@ class IPAddressAssignTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface')
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
orderable = False
|
||||
|
||||
|
||||
@@ -361,3 +374,21 @@ class VLANDetailTable(VLANTable):
|
||||
|
||||
class Meta(VLANTable.Meta):
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
|
||||
name = tables.Column(verbose_name='Interface')
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
orderable=False
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('parent', 'name', 'untagged', 'actions')
|
||||
|
||||
@@ -80,6 +80,7 @@ urlpatterns = [
|
||||
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
|
||||
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
|
||||
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
|
||||
|
||||
@@ -729,8 +729,8 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
|
||||
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
|
||||
).filter(
|
||||
vrf=form.cleaned_data['vrf'],
|
||||
address__net_host=form.cleaned_data['address'],
|
||||
)
|
||||
address__istartswith=form.cleaned_data['address'],
|
||||
)[:100] # Limit to 100 results
|
||||
table = tables.IPAddressAssignTable(queryset)
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
@@ -851,6 +851,38 @@ class VLANView(View):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
|
||||
members = vlan.get_members().select_related('device', 'virtual_machine')
|
||||
|
||||
members_table = tables.VLANMemberTable(members)
|
||||
# if request.user.has_perm('dcim.change_interface'):
|
||||
# members_table.columns.show('pk')
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
# permissions = {
|
||||
# 'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
# 'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
# }
|
||||
|
||||
return render(request, 'ipam/vlan_members.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
# 'permissions': permissions,
|
||||
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
|
||||
})
|
||||
|
||||
|
||||
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.add_vlan'
|
||||
model = VLAN
|
||||
|
||||
@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.3.0'
|
||||
VERSION = '2.3.3'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -133,7 +133,6 @@ INSTALLED_APPS = (
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'rest_framework_swagger',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
'dcim',
|
||||
@@ -144,6 +143,7 @@ INSTALLED_APPS = (
|
||||
'users',
|
||||
'utilities',
|
||||
'virtualization',
|
||||
'drf_yasg',
|
||||
)
|
||||
|
||||
# Middleware
|
||||
@@ -246,6 +246,32 @@ REST_FRAMEWORK = {
|
||||
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
|
||||
}
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||
'drf_yasg.inspectors.RelatedFieldInspector',
|
||||
'drf_yasg.inspectors.ChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.FileFieldInspector',
|
||||
'drf_yasg.inspectors.DictFieldInspector',
|
||||
'drf_yasg.inspectors.SimpleFieldInspector',
|
||||
'drf_yasg.inspectors.StringDefaultFieldInspector',
|
||||
],
|
||||
'DEFAULT_FILTER_INSPECTORS': [
|
||||
'utilities.custom_inspectors.IdInFilterInspector',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
],
|
||||
'DEFAULT_PAGINATOR_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullablePaginatorInspector',
|
||||
'drf_yasg.inspectors.DjangoRestResponsePagination',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Django debug toolbar
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
|
||||
@@ -4,12 +4,24 @@ from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.static import serve
|
||||
from rest_framework_swagger.views import get_swagger_view
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
from netbox.views import APIRootView, HomeView, SearchView
|
||||
from users.views import LoginView, LogoutView
|
||||
|
||||
swagger_view = get_swagger_view(title='NetBox API')
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="NetBox API",
|
||||
default_version='v2',
|
||||
description="API to access NetBox",
|
||||
terms_of_service="https://github.com/digitalocean/netbox",
|
||||
contact=openapi.Contact(email="netbox@digitalocean.com"),
|
||||
license=openapi.License(name="Apache v2 License"),
|
||||
),
|
||||
validators=['flex', 'ssv'],
|
||||
public=True,
|
||||
)
|
||||
|
||||
_patterns = [
|
||||
|
||||
@@ -40,7 +52,9 @@ _patterns = [
|
||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||
url(r'^api/virtualization/', include('virtualization.api.urls')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'),
|
||||
url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'),
|
||||
url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
|
||||
@@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
|
||||
#
|
||||
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
plaintext = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
|
||||
)
|
||||
plaintext2 = forms.CharField(
|
||||
max_length=65535,
|
||||
required=False,
|
||||
label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(SecretForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# A plaintext value is required when creating a new Secret
|
||||
if not self.instance.pk:
|
||||
self.fields['plaintext'].required = True
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that the provided plaintext values match
|
||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||
raise forms.ValidationError({
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
|
||||
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
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>
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" title="Delete outlet" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Delete outlet" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,16 +13,44 @@
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
</div>
|
||||
{% if obj.mode %}
|
||||
<div class="panel panel-default" id="vlans_panel">
|
||||
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
|
||||
{% include 'dcim/inc/interface_vlans_table.html' %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
|
||||
<i class="glyphicon glyphicon-plus"></i> Add VLANs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
<button type="submit" formaction="?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#clear_untagged_vlan').click(function () {
|
||||
$('input[name="untagged_vlan"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
$('#clear_tagged_vlans').click(function () {
|
||||
$('input[name="tagged_vlans"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{% render_field form.facility %}
|
||||
{% render_field form.asn %}
|
||||
{% render_field form.time_zone %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
||||
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>
|
||||
@@ -39,7 +39,7 @@
|
||||
</form>
|
||||
{% if table %}
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-md-offset-1" style="margin-top: 20px">
|
||||
<div class="col-md-12" style="margin-top: 20px">
|
||||
<h3>Search Results</h3>
|
||||
{% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,7 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
|
||||
{% if vlan.site %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
|
||||
{% endif %}
|
||||
{% if vlan.group %}
|
||||
<li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ vlan }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'ipam:vlan_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search VLANs" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if perms.ipam.change_vlan %}
|
||||
<a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_vlan %}
|
||||
<a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this VLAN
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=vlan %}
|
||||
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
||||
12
netbox/templates/ipam/vlan_members.html
Normal file
12
netbox/templates/ipam/vlan_members.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block title %}{{ vlan }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -31,13 +31,15 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3 text-right">
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
53
netbox/templates/virtualization/interface_edit.html
Normal file
53
netbox/templates/virtualization/interface_edit.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.name %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.mac_address %}
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.mode %}
|
||||
</div>
|
||||
</div>
|
||||
{% if obj.mode %}
|
||||
<div class="panel panel-default" id="vlans_panel">
|
||||
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
|
||||
{% include 'dcim/inc/interface_vlans_table.html' %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
|
||||
<i class="glyphicon glyphicon-plus"></i> Add VLANs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block buttons %}
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
<button type="submit" formaction="?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#clear_untagged_vlan').click(function () {
|
||||
$('input[name="untagged_vlan"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
$('#clear_tagged_vlans').click(function () {
|
||||
$('input[name="tagged_vlans"]').prop("checked", false);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,6 +5,7 @@ import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import ManyToManyField
|
||||
from django.http import Http404
|
||||
from rest_framework import mixins
|
||||
from rest_framework.exceptions import APIException
|
||||
@@ -51,6 +52,11 @@ class ValidatedModelSerializer(ModelSerializer):
|
||||
|
||||
# Run clean() on an instance of the model
|
||||
if self.instance is None:
|
||||
model = self.Meta.model
|
||||
# Ignore ManyToManyFields for new instances (a PK is needed for validation)
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, ManyToManyField) and field.name in attrs:
|
||||
attrs.pop(field.name)
|
||||
instance = self.Meta.model(**attrs)
|
||||
else:
|
||||
instance = self.instance
|
||||
|
||||
76
netbox/utilities/custom_inspectors.py
Normal file
76
netbox/utilities/custom_inspectors.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
|
||||
from rest_framework.fields import ChoiceField
|
||||
|
||||
from extras.api.customfields import CustomFieldsSerializer
|
||||
from utilities.api import ChoiceFieldSerializer
|
||||
|
||||
|
||||
class CustomChoiceFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
# this returns a callable which extracts title, description and other stuff
|
||||
# https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
|
||||
SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
|
||||
if isinstance(field, ChoiceFieldSerializer):
|
||||
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
|
||||
|
||||
choices = list(field._choices.keys())
|
||||
if set([None] + choices) == {None, True, False}:
|
||||
# DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be
|
||||
# differentiated since they each have subtly different values in their choice keys.
|
||||
# - subdevice_role and connection_status are booleans, although subdevice_role includes None
|
||||
# - face is an integer set {0, 1} which is easily confused with {False, True}
|
||||
schema_type = openapi.TYPE_INTEGER
|
||||
if all(type(x) == bool for x in [c for c in choices if c is not None]):
|
||||
schema_type = openapi.TYPE_BOOLEAN
|
||||
value_schema = openapi.Schema(type=schema_type)
|
||||
value_schema['x-nullable'] = True
|
||||
|
||||
schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={
|
||||
"label": openapi.Schema(type=openapi.TYPE_STRING),
|
||||
"value": value_schema
|
||||
})
|
||||
|
||||
return schema
|
||||
|
||||
elif isinstance(field, CustomFieldsSerializer):
|
||||
schema = SwaggerType(type=openapi.TYPE_OBJECT)
|
||||
return schema
|
||||
|
||||
return NotHandled
|
||||
|
||||
|
||||
class NullableBooleanFieldInspector(FieldInspector):
|
||||
def process_result(self, result, method_name, obj, **kwargs):
|
||||
|
||||
if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean':
|
||||
keys = obj.choices.keys()
|
||||
if set(keys) == {None, True, False}:
|
||||
result['x-nullable'] = True
|
||||
result.type = 'boolean'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class IdInFilterInspector(FilterInspector):
|
||||
def process_result(self, result, method_name, obj, **kwargs):
|
||||
if isinstance(result, list):
|
||||
params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in']
|
||||
for p in params:
|
||||
p.type = 'string'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class NullablePaginatorInspector(PaginatorInspector):
|
||||
def process_result(self, result, method_name, obj, **kwargs):
|
||||
if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
|
||||
next = result.properties['next']
|
||||
if isinstance(next, openapi.Schema):
|
||||
next['x-nullable'] = True
|
||||
previous = result.properties['previous']
|
||||
if isinstance(previous, openapi.Schema):
|
||||
previous['x-nullable'] = True
|
||||
|
||||
return result
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.urls import reverse_lazy
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
|
||||
@@ -38,6 +39,7 @@ COLOR_CHOICES = (
|
||||
('111111', 'Black'),
|
||||
)
|
||||
NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
|
||||
ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
|
||||
IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
|
||||
IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
|
||||
|
||||
@@ -76,6 +78,45 @@ def expand_numeric_pattern(string):
|
||||
yield "{}{}{}".format(lead, i, remnant)
|
||||
|
||||
|
||||
def parse_alphanumeric_range(string):
|
||||
"""
|
||||
Expand an alphanumeric range (continuous or not) into a list.
|
||||
'a-d,f' => [a, b, c, d, f]
|
||||
'0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
|
||||
"""
|
||||
values = []
|
||||
for dash_range in string.split(','):
|
||||
try:
|
||||
begin, end = dash_range.split('-')
|
||||
vals = begin + end
|
||||
# Break out of loop if there's an invalid pattern to return an error
|
||||
if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
|
||||
return []
|
||||
except ValueError:
|
||||
begin, end = dash_range, dash_range
|
||||
if begin.isdigit() and end.isdigit():
|
||||
for n in list(range(int(begin), int(end) + 1)):
|
||||
values.append(n)
|
||||
else:
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
return values
|
||||
|
||||
|
||||
def expand_alphanumeric_pattern(string):
|
||||
"""
|
||||
Expand an alphabetic pattern into a list of strings.
|
||||
"""
|
||||
lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
|
||||
parsed_range = parse_alphanumeric_range(pattern)
|
||||
for i in parsed_range:
|
||||
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
|
||||
for string in expand_alphanumeric_pattern(remnant):
|
||||
yield "{}{}{}".format(lead, i, string)
|
||||
else:
|
||||
yield "{}{}{}".format(lead, i, remnant)
|
||||
|
||||
|
||||
def expand_ipaddress_pattern(string, family):
|
||||
"""
|
||||
Expand an IP address pattern into a list of strings. Examples:
|
||||
@@ -164,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
# Split the delimited string of values into a list
|
||||
value = value[0].split(self.delimiter)
|
||||
if value:
|
||||
value = value[0].split(self.delimiter)
|
||||
return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
@@ -305,12 +347,15 @@ class ExpandableNameField(forms.CharField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ExpandableNameField, self).__init__(*args, **kwargs)
|
||||
if not self.help_text:
|
||||
self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
|
||||
'Example: <code>ge-0/0/[0-23,25,30]</code>'
|
||||
self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
|
||||
'Mixed cases and types within a single range are not supported.<br />' \
|
||||
'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
|
||||
'<li><code>e[0-3][a-d,f]</code></li>' \
|
||||
'<li><code>e[0-3,a-d,f]</code></li></ul>'
|
||||
|
||||
def to_python(self, value):
|
||||
if re.search(NUMERIC_EXPANSION_PATTERN, value):
|
||||
return list(expand_numeric_pattern(value))
|
||||
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
|
||||
return list(expand_alphanumeric_pattern(value))
|
||||
return [value]
|
||||
|
||||
|
||||
@@ -450,6 +495,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
|
||||
pass
|
||||
|
||||
|
||||
class AnnotatedMultipleChoiceField(forms.MultipleChoiceField):
|
||||
"""
|
||||
Render a set of static choices with each choice annotated to include a count of related objects. For example, this
|
||||
field can be used to display a list of all available device statuses along with the number of devices currently
|
||||
assigned to each status.
|
||||
"""
|
||||
|
||||
def annotate_choices(self):
|
||||
queryset = self.annotate.values(
|
||||
self.annotate_field
|
||||
).annotate(
|
||||
count=Count(self.annotate_field)
|
||||
).order_by(
|
||||
self.annotate_field
|
||||
)
|
||||
choice_counts = {
|
||||
c[self.annotate_field]: c['count'] for c in queryset
|
||||
}
|
||||
annotated_choices = [
|
||||
(c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices
|
||||
]
|
||||
|
||||
return annotated_choices
|
||||
|
||||
def __init__(self, choices, annotate, annotate_field, *args, **kwargs):
|
||||
self.annotate = annotate
|
||||
self.annotate_field = annotate_field
|
||||
self.static_choices = choices
|
||||
|
||||
super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs)
|
||||
|
||||
|
||||
class LaxURLField(forms.URLField):
|
||||
"""
|
||||
Modifies Django's built-in URLField in two ways:
|
||||
@@ -539,9 +616,11 @@ class ComponentForm(BootstrapMixin, forms.Form):
|
||||
|
||||
class BulkEditForm(forms.Form):
|
||||
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
def __init__(self, model, parent_obj=None, *args, **kwargs):
|
||||
super(BulkEditForm, self).__init__(*args, **kwargs)
|
||||
self.model = model
|
||||
self.parent_obj = parent_obj
|
||||
|
||||
# Copy any nullable fields defined in Meta
|
||||
if hasattr(self.Meta, 'nullable_fields'):
|
||||
self.nullable_fields = [field for field in self.Meta.nullable_fields]
|
||||
|
||||
@@ -14,7 +14,7 @@ def csv_format(data):
|
||||
for value in data:
|
||||
|
||||
# Represent None or False with empty string
|
||||
if value in [None, False]:
|
||||
if value is None or value is False:
|
||||
csv.append('')
|
||||
continue
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class CustomFieldQueryset:
|
||||
|
||||
def __init__(self, queryset, custom_fields):
|
||||
self.queryset = queryset
|
||||
self.model = queryset.model
|
||||
self.custom_fields = custom_fields
|
||||
|
||||
def __iter__(self):
|
||||
@@ -506,7 +507,7 @@ class BulkEditView(View):
|
||||
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
||||
|
||||
if '_apply' in request.POST:
|
||||
form = self.form(self.cls, request.POST)
|
||||
form = self.form(self.cls, parent_obj, request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
||||
@@ -564,7 +565,7 @@ class BulkEditView(View):
|
||||
else:
|
||||
initial_data = request.POST.copy()
|
||||
initial_data['pk'] = pk_list
|
||||
form = self.form(self.cls, initial=initial_data)
|
||||
form = self.form(self.cls, parent_obj, initial=initial_data)
|
||||
|
||||
# Retrieve objects being edited
|
||||
queryset = self.queryset or self.cls.objects.all()
|
||||
|
||||
@@ -3,10 +3,10 @@ from __future__ import unicode_literals
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
|
||||
from dcim.constants import IFACE_FF_VIRTUAL
|
||||
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
|
||||
from dcim.models import Interface
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.constants import VM_STATUS_CHOICES
|
||||
@@ -133,13 +133,26 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
# VM interfaces
|
||||
#
|
||||
|
||||
# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
|
||||
class InterfaceVLANSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
|
||||
untagged_vlan = InterfaceVLANSerializer()
|
||||
tagged_vlans = InterfaceVLANSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description',
|
||||
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -157,5 +170,6 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
|
||||
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan',
|
||||
'tagged_vlans', 'description',
|
||||
]
|
||||
|
||||
@@ -25,11 +25,13 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
class ClusterTypeViewSet(ModelViewSet):
|
||||
queryset = ClusterType.objects.all()
|
||||
serializer_class = serializers.ClusterTypeSerializer
|
||||
filter_class = filters.ClusterTypeFilter
|
||||
|
||||
|
||||
class ClusterGroupViewSet(ModelViewSet):
|
||||
queryset = ClusterGroup.objects.all()
|
||||
serializer_class = serializers.ClusterGroupSerializer
|
||||
filter_class = filters.ClusterGroupFilter
|
||||
|
||||
|
||||
class ClusterViewSet(CustomFieldModelViewSet):
|
||||
|
||||
@@ -13,6 +13,20 @@ from .constants import VM_STATUS_CHOICES
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class ClusterTypeFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ClusterType
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class ClusterGroupFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ClusterGroup
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class ClusterFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
|
||||
from dcim.constants import IFACE_FF_VIRTUAL
|
||||
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
|
||||
from dcim.forms import INTERFACE_MODE_HELP_TEXT
|
||||
from dcim.formfields import MACAddressFormField
|
||||
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
|
||||
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
|
||||
@@ -13,9 +14,9 @@ from ipam.models import IPAddress
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
|
||||
)
|
||||
from .constants import VM_STATUS_CHOICES
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
|
||||
|
||||
|
||||
def vm_status_choices():
|
||||
status_counts = {}
|
||||
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VirtualMachine
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
|
||||
status = AnnotatedMultipleChoiceField(
|
||||
choices=VM_STATUS_CHOICES,
|
||||
annotate=VirtualMachine.objects.all(),
|
||||
annotate_field='status',
|
||||
required=False
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
|
||||
to_field_name='slug',
|
||||
@@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'virtual_machine': forms.HiddenInput(),
|
||||
'form_factor': forms.HiddenInput(),
|
||||
}
|
||||
labels = {
|
||||
'mode': '802.1Q Mode',
|
||||
}
|
||||
help_texts = {
|
||||
'mode': INTERFACE_MODE_HELP_TEXT,
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(InterfaceForm, self).clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
tagged_vlans = self.cleaned_data['tagged_vlans']
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
|
||||
raise forms.ValidationError({
|
||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||
})
|
||||
|
||||
# Remove all tagged VLAN assignments from "tagged all" interfaces
|
||||
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
@@ -442,7 +467,6 @@ class InterfaceCreateForm(ComponentForm):
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
|
||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
@@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def site(self):
|
||||
# used when a child compent (eg Interface) needs to know its parent's site but
|
||||
# the parent could be either a device or a virtual machine
|
||||
return self.cluster.site
|
||||
|
||||
@@ -329,6 +329,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
model_form = forms.InterfaceForm
|
||||
template_name = 'virtualization/interface_edit.html'
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
django-rest-swagger
|
||||
psycopg2
|
||||
pycrypto
|
||||
|
||||
@@ -3,10 +3,10 @@ django-cors-headers>=2.1.0
|
||||
django-debug-toolbar>=1.9.0
|
||||
django-filter>=1.1.0
|
||||
django-mptt>=0.9.0
|
||||
django-rest-swagger>=2.1.0
|
||||
django-tables2>=1.19.0
|
||||
django-timezone-field>=2.0
|
||||
djangorestframework>=3.7.7
|
||||
drf-yasg[validation]>=1.4.4
|
||||
graphviz>=0.8.2
|
||||
Markdown>=2.6.11
|
||||
natsort>=5.2.0
|
||||
|
||||
Reference in New Issue
Block a user