mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -06:00
commit
ec4d28ac6c
@ -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
|
# 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:
|
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
|
@ -731,15 +731,20 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
# Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
|
# All associated VLANs be global or assigned to the parent device's site.
|
||||||
# VirtualMachine, or are global.
|
device = self.instance.device if self.instance else data.get('device')
|
||||||
parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
|
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', []):
|
for vlan in data.get('tagged_vlans', []):
|
||||||
if vlan.site not in [parent, None]:
|
if vlan.site not in [device.site, None]:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError({
|
||||||
"Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
|
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||||
"global".format(vlan)
|
"be global.".format(vlan)
|
||||||
)
|
})
|
||||||
|
|
||||||
return super(WritableInterfaceSerializer, self).validate(data)
|
return super(WritableInterfaceSerializer, self).validate(data)
|
||||||
|
|
||||||
|
@ -1679,6 +1679,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
|||||||
label='Untagged VLAN',
|
label='Untagged VLAN',
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||||
|
display_field='display_name'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||||
@ -1691,6 +1692,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
|||||||
label='Tagged VLANs',
|
label='Tagged VLANs',
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||||
|
display_field='display_name'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1728,10 +1730,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
|||||||
self.fields['site'].initial = None
|
self.fields['site'].initial = None
|
||||||
|
|
||||||
# Limit the initial vlan choices
|
# Limit the initial vlan choices
|
||||||
if self.is_bound:
|
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
'group_id': self.data.get('vlan_group') or None,
|
'group_id': self.data.get('vlan_group'),
|
||||||
'site_id': self.data.get('site') or None,
|
'site_id': self.data.get('site'),
|
||||||
}
|
}
|
||||||
elif self.initial.get('untagged_vlan'):
|
elif self.initial.get('untagged_vlan'):
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
@ -1854,10 +1856,10 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
|||||||
self.fields['site'].initial = None
|
self.fields['site'].initial = None
|
||||||
|
|
||||||
# Limit the initial vlan choices
|
# Limit the initial vlan choices
|
||||||
if self.is_bound:
|
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
'group_id': self.data.get('vlan_group') or None,
|
'group_id': self.data.get('vlan_group'),
|
||||||
'site_id': self.data.get('site') or None,
|
'site_id': self.data.get('site'),
|
||||||
}
|
}
|
||||||
elif self.initial.get('untagged_vlan'):
|
elif self.initial.get('untagged_vlan'):
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
@ -1881,7 +1883,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
|||||||
|
|
||||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
|
||||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||||
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||||
@ -1941,17 +1942,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
|||||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||||
device = None
|
device = self.parent_obj
|
||||||
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
|
|
||||||
if device is not None:
|
if device is not None:
|
||||||
interface_ordering = device.device_type.interface_ordering
|
interface_ordering = device.device_type.interface_ordering
|
||||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||||
@ -1968,10 +1959,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
|||||||
self.fields['site'].queryset = Site.objects.none()
|
self.fields['site'].queryset = Site.objects.none()
|
||||||
self.fields['site'].initial = None
|
self.fields['site'].initial = None
|
||||||
|
|
||||||
if self.is_bound:
|
if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
'group_id': self.data.get('vlan_group') or None,
|
'group_id': self.data.get('vlan_group'),
|
||||||
'site_id': self.data.get('site') or None,
|
'site_id': self.data.get('site'),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
filter_dict = {
|
filter_dict = {
|
||||||
@ -2067,7 +2058,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
|||||||
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Initialize interface A choices
|
# 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'
|
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||||
)
|
)
|
||||||
self.fields['interface_a'].choices = [
|
self.fields['interface_a'].choices = [
|
||||||
@ -2076,9 +2067,11 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
|||||||
|
|
||||||
# Mark connected interfaces as disabled
|
# Mark connected interfaces as disabled
|
||||||
if self.data.get('device_b'):
|
if self.data.get('device_b'):
|
||||||
self.fields['interface_b'].choices = [
|
self.fields['interface_b'].choices = []
|
||||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
|
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):
|
class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||||
@ -2298,11 +2291,12 @@ class BaseVCMemberFormSet(forms.BaseModelFormSet):
|
|||||||
# Check for duplicate VC position values
|
# Check for duplicate VC position values
|
||||||
vc_position_list = []
|
vc_position_list = []
|
||||||
for form in self.forms:
|
for form in self.forms:
|
||||||
vc_position = form.cleaned_data['vc_position']
|
vc_position = form.cleaned_data.get('vc_position')
|
||||||
if vc_position in vc_position_list:
|
if vc_position:
|
||||||
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
if vc_position in vc_position_list:
|
||||||
form.add_error('vc_position', error_msg)
|
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||||
vc_position_list.append(vc_position)
|
form.add_error('vc_position', error_msg)
|
||||||
|
vc_position_list.append(vc_position)
|
||||||
|
|
||||||
|
|
||||||
class DeviceVCMembershipForm(forms.ModelForm):
|
class DeviceVCMembershipForm(forms.ModelForm):
|
||||||
|
@ -12,6 +12,7 @@ from dcim.models import (
|
|||||||
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
|
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
|
||||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||||
)
|
)
|
||||||
|
from ipam.models import VLAN
|
||||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||||
from users.models import Token
|
from users.models import Token
|
||||||
from utilities.tests import HttpStatusMixin
|
from utilities.tests import HttpStatusMixin
|
||||||
@ -2258,6 +2259,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
|||||||
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
|
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.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):
|
def test_get_interface(self):
|
||||||
|
|
||||||
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
|
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
|
||||||
@ -2309,6 +2314,26 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(interface4.device_id, data['device'])
|
self.assertEqual(interface4.device_id, data['device'])
|
||||||
self.assertEqual(interface4.name, data['name'])
|
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):
|
def test_create_interface_bulk(self):
|
||||||
|
|
||||||
data = [
|
data = [
|
||||||
@ -2335,6 +2360,44 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
|
|||||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||||
self.assertEqual(response.data[2]['name'], data[2]['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):
|
def test_update_interface(self):
|
||||||
|
|
||||||
lag_interface = Interface.objects.create(
|
lag_interface = Interface.objects.create(
|
||||||
|
@ -7,7 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
|
|||||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
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.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -2082,14 +2082,13 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
|
|||||||
# Get the list of devices being added to a VirtualChassis
|
# Get the list of devices being added to a VirtualChassis
|
||||||
pk_form = forms.DeviceSelectionForm(request.POST)
|
pk_form = forms.DeviceSelectionForm(request.POST)
|
||||||
pk_form.full_clean()
|
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(
|
device_queryset = Device.objects.filter(
|
||||||
pk__in=pk_form.cleaned_data.get('pk')
|
pk__in=pk_form.cleaned_data.get('pk')
|
||||||
).select_related('rack').order_by('vc_position')
|
).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(
|
VCMemberFormSet = modelformset_factory(
|
||||||
model=Device,
|
model=Device,
|
||||||
formset=forms.BaseVCMemberFormSet,
|
formset=forms.BaseVCMemberFormSet,
|
||||||
|
@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
|||||||
DeprecationWarning
|
DeprecationWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
VERSION = '2.3.0'
|
VERSION = '2.3.1'
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -124,7 +124,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -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>
|
<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 %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_inventoryitem %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -5,6 +5,7 @@ import pytz
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db.models import ManyToManyField
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
@ -51,6 +52,11 @@ class ValidatedModelSerializer(ModelSerializer):
|
|||||||
|
|
||||||
# Run clean() on an instance of the model
|
# Run clean() on an instance of the model
|
||||||
if self.instance is None:
|
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)
|
instance = self.Meta.model(**attrs)
|
||||||
else:
|
else:
|
||||||
instance = self.instance
|
instance = self.instance
|
||||||
|
@ -539,9 +539,11 @@ class ComponentForm(BootstrapMixin, forms.Form):
|
|||||||
|
|
||||||
class BulkEditForm(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)
|
super(BulkEditForm, self).__init__(*args, **kwargs)
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.parent_obj = parent_obj
|
||||||
|
|
||||||
# Copy any nullable fields defined in Meta
|
# Copy any nullable fields defined in Meta
|
||||||
if hasattr(self.Meta, 'nullable_fields'):
|
if hasattr(self.Meta, 'nullable_fields'):
|
||||||
self.nullable_fields = [field for field in self.Meta.nullable_fields]
|
self.nullable_fields = [field for field in self.Meta.nullable_fields]
|
||||||
|
@ -35,6 +35,7 @@ class CustomFieldQueryset:
|
|||||||
|
|
||||||
def __init__(self, queryset, custom_fields):
|
def __init__(self, queryset, custom_fields):
|
||||||
self.queryset = queryset
|
self.queryset = queryset
|
||||||
|
self.model = queryset.model
|
||||||
self.custom_fields = custom_fields
|
self.custom_fields = custom_fields
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
@ -506,7 +507,7 @@ class BulkEditView(View):
|
|||||||
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
|
||||||
|
|
||||||
if '_apply' in request.POST:
|
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():
|
if form.is_valid():
|
||||||
|
|
||||||
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
||||||
@ -564,7 +565,7 @@ class BulkEditView(View):
|
|||||||
else:
|
else:
|
||||||
initial_data = request.POST.copy()
|
initial_data = request.POST.copy()
|
||||||
initial_data['pk'] = pk_list
|
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
|
# Retrieve objects being edited
|
||||||
queryset = self.queryset or self.cls.objects.all()
|
queryset = self.queryset or self.cls.objects.all()
|
||||||
|
@ -25,11 +25,13 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
|
|||||||
class ClusterTypeViewSet(ModelViewSet):
|
class ClusterTypeViewSet(ModelViewSet):
|
||||||
queryset = ClusterType.objects.all()
|
queryset = ClusterType.objects.all()
|
||||||
serializer_class = serializers.ClusterTypeSerializer
|
serializer_class = serializers.ClusterTypeSerializer
|
||||||
|
filter_class = filters.ClusterTypeFilter
|
||||||
|
|
||||||
|
|
||||||
class ClusterGroupViewSet(ModelViewSet):
|
class ClusterGroupViewSet(ModelViewSet):
|
||||||
queryset = ClusterGroup.objects.all()
|
queryset = ClusterGroup.objects.all()
|
||||||
serializer_class = serializers.ClusterGroupSerializer
|
serializer_class = serializers.ClusterGroupSerializer
|
||||||
|
filter_class = filters.ClusterGroupFilter
|
||||||
|
|
||||||
|
|
||||||
class ClusterViewSet(CustomFieldModelViewSet):
|
class ClusterViewSet(CustomFieldModelViewSet):
|
||||||
|
@ -13,6 +13,20 @@ from .constants import VM_STATUS_CHOICES
|
|||||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
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):
|
class ClusterFilter(CustomFieldFilterSet):
|
||||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
|
@ -442,7 +442,6 @@ class InterfaceCreateForm(ComponentForm):
|
|||||||
|
|
||||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
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)
|
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
|
||||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||||
description = forms.CharField(max_length=100, required=False)
|
description = forms.CharField(max_length=100, required=False)
|
||||||
|
Loading…
Reference in New Issue
Block a user