Compare commits

..

1 Commits

Author SHA1 Message Date
Jeremy Stretch
1033c8677a Release v2.3-beta2 2018-02-06 15:12:31 -05:00
76 changed files with 256 additions and 757 deletions

View File

@@ -5,7 +5,7 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects
* `POST`: Create a new object
* `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifying the field to be changed
* `PATCH`: Updates an existing object, only specifiying the field to be changed
* `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header:
@@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
* Closing connection 0
```
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.

View File

@@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
# hierarchy.
# heirarchy.
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
"(objectClass=group)")
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

View File

@@ -91,7 +91,9 @@ Checking connectivity... done.
!!! warning
Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
```
# chown -R netbox:netbox /opt/netbox/netbox/media/
```
## Install Python Packages

View File

@@ -21,12 +21,6 @@ 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

View File

@@ -1,4 +1,4 @@
NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
```
./manage.py nbshell
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
982
```
Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
```
>>> Device.objects.filter(tenant__name='Pied Piper')

View File

@@ -731,20 +731,15 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
def validate(self, data):
# 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)
})
# 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')
for vlan in data.get('tagged_vlans', []):
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)
})
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)
)
return super(WritableInterfaceSerializer, self).validate(data)

View File

@@ -684,46 +684,11 @@ class InventoryItemFilter(DeviceComponentFilterSet):
class VirtualChassisFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='master__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='master__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='master__tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='master__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta:
model = VirtualChassis
fields = ['domain']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(master__name__icontains=value) |
Q(domain__icontains=value)
)
return queryset.filter(qs_filter)
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter(

View File

@@ -17,8 +17,8 @@ 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,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .constants import (
@@ -1086,15 +1086,6 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
)
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
mac_address = forms.CharField(required=False, label='MAC address')
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
])
)
#
@@ -1679,7 +1670,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
label='Untagged VLAN',
widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
display_field='display_name'
)
)
tagged_vlans = ChainedModelMultipleChoiceField(
@@ -1692,14 +1682,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
label='Tagged VLANs',
widget=APISelectMultiple(
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
display_field='display_name'
)
)
class Meta:
model = Interface
fields = [
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
]
widgets = {
@@ -1730,10 +1719,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
'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 = {
@@ -1779,11 +1768,7 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(
required=False,
label='OOB Management',
help_text='This interface is used only for out-of-band management'
)
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
site = forms.ModelChoiceField(
@@ -1856,10 +1841,10 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
self.fields['site'].initial = None
# Limit the initial vlan choices
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
if self.is_bound:
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
'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 = {
@@ -1883,6 +1868,7 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
@@ -1942,7 +1928,12 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
device = self.parent_obj
device = None
if self.initial.get('device'):
try:
device = Device.objects.get(pk=self.initial.get('device'))
except Device.DoesNotExist:
pass
if device is not None:
interface_ordering = device.device_type.interface_ordering
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
@@ -1959,16 +1950,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
self.fields['site'].queryset = Site.objects.none()
self.fields['site'].initial = None
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
filter_dict = {
'group_id': self.data.get('vlan_group'),
'site_id': self.data.get('site'),
}
else:
filter_dict = {
'group_id': None,
'site_id': None,
}
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)
@@ -2058,7 +2043,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
# Initialize interface A choices
device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related(
device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface_a'].choices = [
@@ -2067,11 +2052,9 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
# Mark connected interfaces as disabled
if self.data.get('device_b'):
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})
)
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
]
class InterfaceConnectionCSVForm(forms.ModelForm):
@@ -2278,61 +2261,6 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = VirtualChassis
fields = ['master', 'domain']
widgets = {
'master': SelectWithPK,
}
class BaseVCMemberFormSet(forms.BaseModelFormSet):
def clean(self):
super(BaseVCMemberFormSet, self).clean()
# Check for duplicate VC position values
vc_position_list = []
for form in self.forms:
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):
class Meta:
model = Device
fields = ['vc_position', 'vc_priority']
labels = {
'vc_position': 'Position',
'vc_priority': 'Priority',
}
def __init__(self, validate_vc_position=False, *args, **kwargs):
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
# Require VC position (only required when the Device is a VirtualChassis member)
self.fields['vc_position'].required = True
# Validation of vc_position is optional. This is only required when adding a new member to an existing
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
self.validate_vc_position = validate_vc_position
def clean_vc_position(self):
vc_position = self.cleaned_data['vc_position']
if self.validate_vc_position:
conflicting_members = Device.objects.filter(
virtual_chassis=self.instance.virtual_chassis,
vc_position=vc_position
)
if conflicting_members.exists():
raise forms.ValidationError(
'A virtual chassis member already exists in position {}.'.format(vc_position)
)
return vc_position
class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -2357,7 +2285,7 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
)
)
device = ChainedModelChoiceField(
queryset=Device.objects.filter(virtual_chassis__isnull=True),
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
@@ -2365,8 +2293,7 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
disabled_indicator='virtual_chassis'
display_field='display_name'
)
)
@@ -2374,18 +2301,27 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
device = self.cleaned_data['device']
if device.virtual_chassis is not None:
raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
return device
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VirtualChassis
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
null_label='-- None --',
)
class DeviceVCMembershipForm(forms.ModelForm):
class Meta:
model = Device
fields = ['vc_position', 'vc_priority']
labels = {
'vc_position': 'Position',
'vc_priority': 'Priority',
}
def __init__(self, *args, **kwargs):
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
# Require VC position when assigning a member
self.fields['vc_position'].required = True
def clean_vc_position(self):
vc_position = self.cleaned_data['vc_position']
if Device.objects.filter(virtual_chassis=self.instance.virtual_chassis, vc_position=vc_position).exists():
raise forms.ValidationError("A virtual chassis member already exists in this position.")
return vc_position

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 14:41
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0054_site_status_timezone_description'),
]
operations = [
migrations.AlterModelOptions(
name='virtualchassis',
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
),
migrations.AlterField(
model_name='virtualchassis',
name='master',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
),
]

View File

@@ -1645,10 +1645,6 @@ class VirtualChassis(models.Model):
blank=True
)
class Meta:
ordering = ['master']
verbose_name_plural = 'virtual chassis'
def __str__(self):
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'

View File

@@ -12,7 +12,6 @@ from dcim.models import (
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
@@ -2259,10 +2258,6 @@ 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})
@@ -2314,26 +2309,6 @@ 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',
'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 = [
@@ -2360,44 +2335,6 @@ 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',
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 5',
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id,
},
{
'device': self.device.pk,
'name': 'Test Interface 6',
'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(

View File

@@ -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 modelformset_factory
from django.forms import ModelChoiceField, ModelForm, modelformset_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -857,7 +857,7 @@ class DeviceView(View):
# VirtualChassis members
if device.virtual_chassis is not None:
vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis).order_by('vc_position')
vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis)
else:
vc_members = []
@@ -2069,8 +2069,6 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class VirtualChassisListView(ObjectListView):
queryset = VirtualChassis.objects.annotate(member_count=Count('members'))
table = tables.VirtualChassisTable
filter = filters.VirtualChassisFilter
filter_form = forms.VirtualChassisFilterForm
template_name = 'dcim/virtualchassis_list.html'
@@ -2082,25 +2080,20 @@ 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'):
device_list = pk_form.cleaned_data.get('pk')
if not device_list:
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')
VCMemberFormSet = modelformset_factory(
model=Device,
formset=forms.BaseVCMemberFormSet,
form=forms.DeviceVCMembershipForm,
extra=0
)
# TODO: Error if any of the devices already belong to a VC
VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
if '_create' in request.POST:
vc_form = forms.VirtualChassisForm(request.POST)
vc_form.fields['master'].queryset = device_queryset
formset = VCMemberFormSet(request.POST, queryset=device_queryset)
formset = VCMemberFormSet(request.POST)
if vc_form.is_valid() and formset.is_valid():
@@ -2118,8 +2111,8 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
else:
vc_form = forms.VirtualChassisForm()
vc_form.fields['master'].queryset = device_queryset
formset = VCMemberFormSet(queryset=device_queryset)
vc_form.fields['master'].queryset = Device.objects.filter(pk__in=device_list)
formset = VCMemberFormSet(queryset=Device.objects.filter(pk__in=device_list))
return render(request, 'dcim/virtualchassis_edit.html', {
'pk_form': pk_form,
@@ -2135,17 +2128,11 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
def get(self, request, pk):
virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
VCMemberFormSet = modelformset_factory(
model=Device,
form=forms.DeviceVCMembershipForm,
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset
formset = VCMemberFormSet(queryset=members_queryset)
vc_form.fields['master'].queryset = virtual_chassis.members.all()
formset = VCMemberFormSet(queryset=virtual_chassis.members.all())
return render(request, 'dcim/virtualchassis_edit.html', {
'vc_form': vc_form,
@@ -2156,17 +2143,11 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
def post(self, request, pk):
virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
VCMemberFormSet = modelformset_factory(
model=Device,
form=forms.DeviceVCMembershipForm,
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset
formset = VCMemberFormSet(request.POST, queryset=members_queryset)
vc_form.fields['master'].queryset = virtual_chassis.members.all()
formset = VCMemberFormSet(request.POST, queryset=virtual_chassis.members.all())
if vc_form.is_valid() and formset.is_valid():
@@ -2226,7 +2207,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, instance=device)
if membership_form.is_valid():

View File

@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
form = CustomFieldForm
def models(self, obj):

View File

@@ -26,16 +26,6 @@ CUSTOMFIELD_TYPE_CHOICES = (
(CF_TYPE_SELECT, 'Selection'),
)
# Custom field filter logic choices
CF_FILTER_DISABLED = 0
CF_FILTER_LOOSE = 1
CF_FILTER_EXACT = 2
CF_FILTER_CHOICES = (
(CF_FILTER_DISABLED, 'Disabled'),
(CF_FILTER_LOOSE, 'Loose'),
(CF_FILTER_EXACT, 'Exact'),
)
# Graph types
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200
@@ -56,16 +46,6 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization
]
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
TOPOLOGYMAP_TYPE_POWER = 3
TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# User action types
ACTION_CREATE = 1
ACTION_IMPORT = 2

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from dcim.models import Site
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .constants import CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
@@ -14,9 +14,8 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
"""
def __init__(self, custom_field, *args, **kwargs):
self.cf_type = custom_field.type
self.filter_logic = custom_field.filter_logic
def __init__(self, cf_type, *args, **kwargs):
self.cf_type = cf_type
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
@@ -42,12 +41,10 @@ class CustomFieldFilter(django_filters.Filter):
except ValueError:
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)
else:
return queryset.filter(custom_field_values__serialized_value__icontains=value)
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value__icontains=value,
)
class CustomFieldFilterSet(django_filters.FilterSet):
@@ -59,9 +56,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
class GraphFilter(django_filters.FilterSet):

View File

@@ -6,7 +6,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment
@@ -15,9 +15,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
Retrieve all CustomFields applicable to the given ContentType
"""
field_dict = OrderedDict()
custom_fields = CustomField.objects.filter(obj_type=content_type)
kwargs = {'obj_type': content_type}
if filterable_only:
custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
kwargs['is_filterable'] = True
custom_fields = CustomField.objects.filter(**kwargs)
for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name))
@@ -34,9 +35,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
if initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
elif initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None

View File

@@ -4,6 +4,14 @@ from __future__ import unicode_literals
from django.db import migrations, models
from extras.models import TopologyMap
def commas_to_semicolons(apps, schema_editor):
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
tm.device_patterns = tm.device_patterns.replace(',', ';')
tm.save()
class Migration(migrations.Migration):
@@ -17,4 +25,5 @@ class Migration(migrations.Migration):
name='device_patterns',
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
),
migrations.RunPython(commas_to_semicolons),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-15 16:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0008_reports'),
]
operations = [
migrations.AddField(
model_name='topologymap',
name='type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
),
]

View File

@@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 19:48
from __future__ import unicode_literals
from django.db import migrations, models
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
def is_filterable_to_filter_logic(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
# Select fields match on primary key only
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
def filter_logic_to_is_filterable(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
class Migration(migrations.Migration):
dependencies = [
('extras', '0009_topologymap_type'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='filter_logic',
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
),
migrations.AlterField(
model_name='customfield',
name='required',
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
),
migrations.AlterField(
model_name='customfield',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
),
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
migrations.RemoveField(
model_name='customfield',
name='is_filterable',
),
]

View File

@@ -16,7 +16,6 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color
from .constants import *
@@ -55,48 +54,22 @@ class CustomFieldModel(object):
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
help_text='The object(s) to which this field applies.'
)
type = models.PositiveSmallIntegerField(
choices=CUSTOMFIELD_TYPE_CHOICES,
default=CF_TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
)
description = models.CharField(
max_length=100,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects or editing an existing object.'
)
filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE,
help_text="Loose matches any instance of a given string; exact matches the entire field."
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
help_text="The object(s) to which this field applies.")
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
name = models.CharField(max_length=50, unique=True)
label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
"provided, the field's name will be used)")
description = models.CharField(max_length=100, blank=True)
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
"new objects or editing an existing object.")
is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
"\"false\" for booleans. N/A for selection "
"fields.")
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
"form")
class Meta:
ordering = ['weight', 'name']
@@ -280,17 +253,7 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
related_name='topology_maps',
blank=True,
null=True,
on_delete=models.CASCADE
)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -312,26 +275,22 @@ class TopologyMap(models.Model):
def render(self, img_format='png'):
from dcim.models import Device
from circuits.models import CircuitTermination
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
G = graphviz.Graph
else:
G = graphviz.Digraph
self.graph = G()
self.graph.graph_attr['ranksep'] = '1'
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = G(name='sg{}'.format(i))
subgraph = graphviz.Graph(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same'
subgraph.graph_attr['directed'] = 'true'
# Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i:
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph
devices = []
@@ -349,64 +308,31 @@ class TopologyMap(models.Model):
for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
self.graph.subgraph(subgraph)
graph.subgraph(subgraph)
# Compile list of all devices
device_superset = Q()
for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
devices = Device.objects.filter(*(device_superset,))
# Draw edges depending on graph type
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
self.add_network_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
self.add_console_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_POWER:
self.add_power_connections(devices)
return self.graph.pipe(format=img_format)
def add_network_connections(self, devices):
from circuits.models import CircuitTermination
from dcim.models import InterfaceConnection
# Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices
)
for c in connections:
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination()
if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices):
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
def add_console_connections(self, devices):
from dcim.models import ConsolePort
# Add all console connections to the graph
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
for cp in console_ports:
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
def add_power_connections(self, devices):
from dcim.models import PowerPort
# Add all power connections to the graph
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
for pp in power_ports:
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
return graph.pipe(format=img_format)
#

View File

@@ -520,14 +520,17 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
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:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == self:
parent.primary_ip6 = None
parent.save()
else:
try:
if ipaddress.address.version == 4:
device = ipaddress.primary_ip4_for
device.primary_ip4 = None
else:
device = ipaddress.primary_ip6_for
device.primary_ip6 = None
device.save()
except Device.DoesNotExist:
pass
return ipaddress

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-07 18:37
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0020_ipaddress_add_role_carp'),
]
operations = [
migrations.AlterModelOptions(
name='vrf',
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
),
]

View File

@@ -37,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta:
ordering = ['name', 'rd']
ordering = ['name']
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.3.1'
VERSION = '2.3.0-beta2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -43,23 +43,17 @@
<h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
<a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
</li>
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
{% if perms.dcim.napalm_read %}
{% if device.status != 1 %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
{% elif not device.platform.napalm_driver %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
{% elif not device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
{% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
{% else %}
{% include 'dcim/inc/device_napalm_tabs.html' %}
<li role="presentation" class="disabled"><a href="#">Status</a></li>
<li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#">Configuration</a></li>
{% endif %}
{% endif %}
</ul>

View File

@@ -1,15 +0,0 @@
{% if not disabled_message %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
</li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
</li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
</li>
{% else %}
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
{% endif %}

View File

@@ -40,7 +40,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a>
{% endif %}

View File

@@ -1,29 +0,0 @@
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>

View File

@@ -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 %}?return_url={{ device.get_absolute_url }}" 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 %}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -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 %}?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>
<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>
{% endif %}
</td>
</tr>

View File

@@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Delete outlet" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" title="Delete outlet" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -44,7 +44,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Delete port" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" title="Delete port" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -45,10 +45,9 @@
{% endblock %}
{% block javascript %}
{% include 'dcim/inc/filter_rack_group.html' %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">
@@ -21,6 +22,34 @@
{% endblock %}
{% block javascript %}
{% include 'dcim/inc/filter_rack_group.html' %}
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -7,7 +7,7 @@
{{ pk_form.pk }}
{{ formset.management_form }}
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="col-md-6 col-md-offset-3">
<h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
{% if vc_form.non_field_errors %}
<div class="panel panel-danger">
@@ -29,9 +29,6 @@
<thead>
<tr>
<th>Device</th>
<th>ID</th>
<th>Rack/Unit</th>
<th>Serial</th>
<th>Position</th>
<th>Priority</th>
<th></th>
@@ -47,33 +44,8 @@
<td>
<a href="{{ device.get_absolute_url }}">{{ device }}</a>
</td>
<td>{{ device.pk }}</td>
<td>
{% if device.rack %}
{{ device.rack }} / {{ device.position }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if device.serial %}
{{ device.serial }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{{ form.vc_position }}
{% if form.vc_position.errors %}
<br /><small class="text-danger">{{ form.vc_position.errors.0 }}</small>
{% endif %}
</td>
<td>
{{ form.vc_priority }}
{% if form.vc_priority.errors %}
<br /><small class="text-danger">{{ form.vc_priority.errors.0 }}</small>
{% endif %}
</td>
<td>{{ form.vc_position }}</td>
<td>{{ form.vc_priority }}</td>
<td>
{% if virtual_chassis.pk %}
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}">
@@ -90,7 +62,7 @@
</div>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2 text-right">
<div class="col-md-6 col-md-offset-3 text-right">
{% if vc_form.instance.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button>
{% else %}

View File

@@ -4,11 +4,8 @@
{% block content %}
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
<div class="col-md-12">
{% include 'utilities/obj_table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -104,7 +104,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Devices</li>
@@ -135,9 +135,6 @@
{% endif %}
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
</li>
<li>
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Device Types</li>
<li>

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load buttons %}
{% load humanize %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -144,7 +144,7 @@
{% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -136,7 +136,7 @@
{% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load buttons %}
{% load humanize %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,7 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load buttons %}
{% load helpers %}
{% block content %}
<div class="pull-right">

View File

@@ -6,7 +6,9 @@
<div class="panel-heading"><strong>Virtual Machine</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.platform %}
</div>
</div>
<div class="panel panel-default">
@@ -16,15 +18,6 @@
{% render_field form.cluster %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Management</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.platform %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Resources</strong></div>
<div class="panel-body">

View File

@@ -5,7 +5,6 @@ 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
@@ -52,11 +51,6 @@ 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

View File

@@ -119,7 +119,7 @@ class ColorSelect(forms.Select):
"""
Extends the built-in Select widget to colorize each <option>.
"""
option_template_name = 'widgets/colorselect_option.html'
option_template_name = 'colorselect_option.html'
def __init__(self, *args, **kwargs):
kwargs['choices'] = COLOR_CHOICES
@@ -144,14 +144,7 @@ class SelectWithDisabled(forms.Select):
Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
'label' (string) and 'disabled' (boolean).
"""
option_template_name = 'widgets/selectwithdisabled_option.html'
class SelectWithPK(forms.Select):
"""
Include the primary key of each option in the option label (e.g. "Router7 (4721)").
"""
option_template_name = 'widgets/select_option_with_pk.html'
option_template_name = 'selectwithdisabled_option.html'
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
@@ -539,11 +532,9 @@ class ComponentForm(BootstrapMixin, forms.Form):
class BulkEditForm(forms.Form):
def __init__(self, model, parent_obj=None, *args, **kwargs):
def __init__(self, model, *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]

View File

@@ -1 +0,0 @@
<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}{% if widget.value %} ({{ widget.value }}){% endif %}</option>

View File

@@ -35,7 +35,6 @@ class CustomFieldQueryset:
def __init__(self, queryset, custom_fields):
self.queryset = queryset
self.model = queryset.model
self.custom_fields = custom_fields
def __iter__(self):
@@ -507,7 +506,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, parent_obj, request.POST)
form = self.form(self.cls, request.POST)
if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
@@ -565,7 +564,7 @@ class BulkEditView(View):
else:
initial_data = request.POST.copy()
initial_data['pk'] = pk_list
form = self.form(self.cls, parent_obj, initial=initial_data)
form = self.form(self.cls, initial=initial_data)
# Retrieve objects being edited
queryset = self.queryset or self.cls.objects.all()

View File

@@ -25,13 +25,11 @@ 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):

View File

@@ -13,20 +13,6 @@ 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(

View File

@@ -9,7 +9,6 @@ from dcim.constants import IFACE_FF_VIRTUAL
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -247,8 +246,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = VirtualMachine
fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments',
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'comments',
]
def __init__(self, *args, **kwargs):
@@ -262,41 +261,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
super(VirtualMachineForm, self).__init__(*args, **kwargs)
if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = [(None, '---------')]
# Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter(
family=family, interface__virtual_machine=self.instance
)
if interface_ips:
ip_choices.append(
('Interface IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
])
)
# Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
family=family, nat_inside__interface__virtual_machine=self.instance
)
if nat_ips:
ip_choices.append(
('NAT IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
])
)
self.fields['primary_ip{}'.format(family)].choices = ip_choices
else:
# An object that doesn't exist yet can't have any IPs assigned to it
self.fields['primary_ip4'].choices = []
self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField(
@@ -442,6 +406,7 @@ 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)

View File

@@ -1,2 +1 @@
psycopg2
pycrypto

View File

@@ -1,20 +1,20 @@
Django>=1.11,<2.0
django-cors-headers>=2.1.0
django-debug-toolbar>=1.9.0
django-cors-headers>=2.1
django-debug-toolbar>=1.8
django-filter>=1.1.0
django-mptt>=0.9.0
django-mptt==0.8.7
django-rest-swagger>=2.1.0
django-tables2>=1.19.0
django-tables2>=1.10.0
django-timezone-field>=2.0
djangorestframework>=3.7.7
graphviz>=0.8.2
Markdown>=2.6.11
natsort>=5.2.0
djangorestframework>=3.6.4
graphviz>=0.6
Markdown>=2.6.7
natsort>=5.0.0
ncclient==0.5.3
netaddr==0.7.18
paramiko>=2.4.0
Pillow>=5.0.0
psycopg2-binary>=2.7.4
paramiko>=2.0.0
Pillow>=4.0.0
psycopg2>=2.7.3
py-gfm>=0.1.3
pycryptodome>=3.4.11
xmltodict>=0.11.0
pycryptodome>=3.4.7
xmltodict>=0.10.2