1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into api2

Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
This commit is contained in:
Jeremy Stretch
2017-02-27 17:04:08 -05:00
20 changed files with 319 additions and 71 deletions

View File

@ -24,7 +24,7 @@ Each group is assigned to a parent site for easy navigation. Hierarchical recurs
### Rack Roles ### Rack Roles
Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Each rack can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices.
--- ---

View File

@ -83,9 +83,11 @@ One IP address can be designated as the network address translation (NAT) IP add
# VLANs # VLANs
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice. A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. ### VLAN Groups
VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site.
--- ---

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -227,14 +227,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
# Limit interface choices # Limit interface choices
if self.is_bound and self.data.get('device'): if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\ interfaces = Interface.objects.filter(device=self.data['device']).exclude(
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', form_factor__in=VIRTUAL_IFACE_TYPES
'connected_as_b') ).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'): elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\ interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', form_factor__in=VIRTUAL_IFACE_TYPES
'connected_as_b') ).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else: else:
interfaces = [] interfaces = []

View File

@ -503,7 +503,6 @@ class WritablePowerPortSerializer(serializers.ModelSerializer):
# Interfaces # Interfaces
# #
class InterfaceSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@ -513,7 +512,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'connection', 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection',
'connected_interface', 'connected_interface',
] ]
@ -541,7 +540,7 @@ class WritableInterfaceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Interface model = Interface
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
# #

View File

@ -8,9 +8,9 @@ from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackRole, Site, RackReservation, RackRole, Site, VIRTUAL_IFACE_TYPES,
) )
@ -391,11 +391,25 @@ class PowerOutletFilter(DeviceComponentFilterSet):
class InterfaceFilter(DeviceComponentFilterSet): class InterfaceFilter(DeviceComponentFilterSet):
type = django_filters.MethodFilter(
action='filter_type',
label='Interface type',
)
class Meta: class Meta:
model = Interface model = Interface
fields = ['name'] fields = ['name']
def filter_type(self, queryset, value):
value = value.strip().lower()
if value == 'physical':
return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'virtual':
return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'lag':
return queryset.filter(form_factor=IFACE_FF_LAG)
return queryset
class DeviceBayFilter(DeviceComponentFilterSet): class DeviceBayFilter(DeviceComponentFilterSet):

View File

@ -18,9 +18,10 @@ from .formfields import MACAddressFormField
from .models import ( from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
VIRTUAL_IFACE_TYPES
) )
@ -53,6 +54,15 @@ def validate_connection_status(value):
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
class DeviceComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent device as context for limiting field choices.
"""
def __init__(self, device, *args, **kwargs):
self.device = device
super(DeviceComponentForm, self).__init__(*args, **kwargs)
# #
# Sites # Sites
# #
@ -331,7 +341,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsolePortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -345,7 +355,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -359,7 +369,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): class PowerPortTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -373,7 +383,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): class PowerOutletTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -387,7 +397,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): class InterfaceTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
mgmt_only = forms.BooleanField(required=False, label='OOB Management') mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@ -411,7 +421,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): class DeviceBayTemplateCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -743,7 +753,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
} }
class ConsolePortCreateForm(BootstrapMixin, forms.Form): class ConsolePortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -914,7 +924,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
} }
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -1012,7 +1022,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
} }
class PowerPortCreateForm(BootstrapMixin, forms.Form): class PowerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -1181,7 +1191,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
} }
class PowerOutletCreateForm(BootstrapMixin, forms.Form): class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -1273,27 +1283,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Interface model = Interface
fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
def __init__(self, *args, **kwargs):
super(InterfaceForm, self).__init__(*args, **kwargs)
class InterfaceCreateForm(BootstrapMixin, forms.Form): # Limit LAG choices to interfaces belonging to this device
if self.is_bound:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device_id=self.data['device'], form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.instance.device, form_factor=IFACE_FF_LAG
)
class InterfaceCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mac_address = MACAddressFormField(required=False, label='MAC Address') mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(required=False, label='OOB Management') mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
def __init__(self, *args, **kwargs):
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device
if self.device is not None:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.device, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
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)
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
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)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta: class Meta:
nullable_fields = ['description'] nullable_fields = ['lag', 'description']
def __init__(self, *args, **kwargs):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device.
if self.initial.get('device'):
self.fields['lag'].queryset = Interface.objects.filter(
device=self.initial['device'], form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].choices = []
# #
@ -1360,8 +1408,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
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.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\ device_a_interfaces = Interface.objects.filter(device=device_a).exclude(
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b') form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface_a'].choices = [ self.fields['interface_a'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
] ]
@ -1388,13 +1439,17 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
# Initialize interface_b choices if device_b is set # Initialize interface_b choices if device_b is set
if self.is_bound: if self.is_bound:
device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\ device_b_interfaces = Interface.objects.filter(device=self.data['device_b']).exclude(
.exclude(form_factor=IFACE_FF_VIRTUAL)\ form_factor__in=VIRTUAL_IFACE_TYPES
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b') ).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
elif self.initial.get('device_b'): elif self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\ device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
.exclude(form_factor=IFACE_FF_VIRTUAL)\ form_factor__in=VIRTUAL_IFACE_TYPES
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b') ).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
else: else:
device_b_interfaces = [] device_b_interfaces = []
self.fields['interface_b'].choices = [ self.fields['interface_b'].choices = [
@ -1512,7 +1567,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
} }
class DeviceBayCreateForm(BootstrapMixin, forms.Form): class DeviceBayCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-27 19:55
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0029_allow_rackless_devices'),
]
operations = [
migrations.AddField(
model_name='interface',
name='lag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name=b'Parent LAG'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

@ -68,6 +68,7 @@ IFACE_ORDERING_CHOICES = [
# Virtual # Virtual
IFACE_FF_VIRTUAL = 0 IFACE_FF_VIRTUAL = 0
IFACE_FF_LAG = 200
# Ethernet # Ethernet
IFACE_FF_100ME_FIXED = 800 IFACE_FF_100ME_FIXED = 800
IFACE_FF_1GE_FIXED = 1000 IFACE_FF_1GE_FIXED = 1000
@ -106,6 +107,7 @@ IFACE_FF_CHOICES = [
'Virtual interfaces', 'Virtual interfaces',
[ [
[IFACE_FF_VIRTUAL, 'Virtual'], [IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
] ]
], ],
[ [
@ -148,6 +150,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_E1, 'E1 (2.048 Mbps)'], [IFACE_FF_E1, 'E1 (2.048 Mbps)'],
[IFACE_FF_T3, 'T3 (45 Mbps)'], [IFACE_FF_T3, 'T3 (45 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'], [IFACE_FF_E3, 'E3 (34 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
] ]
], ],
[ [
@ -167,6 +170,11 @@ IFACE_FF_CHOICES = [
], ],
] ]
VIRTUAL_IFACE_TYPES = [
IFACE_FF_VIRTUAL,
IFACE_FF_LAG,
]
STATUS_ACTIVE = True STATUS_ACTIVE = True
STATUS_OFFLINE = False STATUS_OFFLINE = False
STATUS_CHOICES = [ STATUS_CHOICES = [
@ -1062,6 +1070,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
return RPC_CLIENTS.get(self.platform.rpc_client) return RPC_CLIENTS.get(self.platform.rpc_client)
#
# Console ports
#
@python_2_unicode_compatible @python_2_unicode_compatible
class ConsolePort(models.Model): class ConsolePort(models.Model):
""" """
@ -1091,6 +1103,10 @@ class ConsolePort(models.Model):
]) ])
#
# Console server ports
#
class ConsoleServerPortManager(models.Manager): class ConsoleServerPortManager(models.Manager):
def get_queryset(self): def get_queryset(self):
@ -1123,6 +1139,10 @@ class ConsoleServerPort(models.Model):
return self.name return self.name
#
# Power ports
#
@python_2_unicode_compatible @python_2_unicode_compatible
class PowerPort(models.Model): class PowerPort(models.Model):
""" """
@ -1152,6 +1172,10 @@ class PowerPort(models.Model):
]) ])
#
# Power outlets
#
class PowerOutletManager(models.Manager): class PowerOutletManager(models.Manager):
def get_queryset(self): def get_queryset(self):
@ -1178,6 +1202,10 @@ class PowerOutlet(models.Model):
return self.name return self.name
#
# Interfaces
#
@python_2_unicode_compatible @python_2_unicode_compatible
class Interface(models.Model): class Interface(models.Model):
""" """
@ -1185,6 +1213,8 @@ class Interface(models.Model):
of an InterfaceConnection. of an InterfaceConnection.
""" """
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='Parent LAG')
name = models.CharField(max_length=30) name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
@ -1203,15 +1233,42 @@ class Interface(models.Model):
def clean(self): def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: # Virtual interfaces cannot be connected
if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected:
raise ValidationError({ raise ValidationError({
'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
"interface or choose a physical form factor." "interface or choose a physical form factor."
}) })
# An interface's LAG must belong to the same device
if self.lag and self.lag.device != self.device:
raise ValidationError({
'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
self.lag.name, self.lag.device.name
)
})
# A LAG interface cannot have a parent LAG
if self.form_factor == IFACE_FF_LAG and self.lag is not None:
raise ValidationError({
'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
})
# Only a LAG can have LAG members
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
raise ValidationError({
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
u", ".join([iface.name for iface in self.member_interfaces.all()])
)
})
@property @property
def is_physical(self): def is_virtual(self):
return self.form_factor != IFACE_FF_VIRTUAL return self.form_factor in VIRTUAL_IFACE_TYPES
@property
def is_lag(self):
return self.form_factor == IFACE_FF_LAG
@property @property
def is_connected(self): def is_connected(self):
@ -1275,6 +1332,10 @@ class InterfaceConnection(models.Model):
]) ])
#
# Device bays
#
@python_2_unicode_compatible @python_2_unicode_compatible
class DeviceBay(models.Model): class DeviceBay(models.Model):
""" """
@ -1305,6 +1366,10 @@ class DeviceBay(models.Model):
raise ValidationError("Cannot install a device into itself.") raise ValidationError("Cannot install a device into itself.")
#
# Modules
#
@python_2_unicode_compatible @python_2_unicode_compatible
class Module(models.Model): class Module(models.Model):
""" """

View File

@ -561,6 +561,7 @@ class InterfaceTest(APITestCase):
'device', 'device',
'name', 'name',
'form_factor', 'form_factor',
'lag',
'mac_address', 'mac_address',
'mgmt_only', 'mgmt_only',
'description', 'description',
@ -574,6 +575,7 @@ class InterfaceTest(APITestCase):
'device', 'device',
'name', 'name',
'form_factor', 'form_factor',
'lag',
'mac_address', 'mac_address',
'mgmt_only', 'mgmt_only',
'description', 'description',

View File

@ -66,11 +66,12 @@ class ComponentCreateView(View):
def get(self, request, pk): def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk) parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, initial=request.GET)
return render(request, 'dcim/device_component_add.html', { return render(request, 'dcim/device_component_add.html', {
'parent': parent, 'parent': parent,
'component_type': self.model._meta.verbose_name, 'component_type': self.model._meta.verbose_name,
'form': self.form(initial=request.GET), 'form': form,
'return_url': parent.get_absolute_url(), 'return_url': parent.get_absolute_url(),
}) })
@ -78,7 +79,7 @@ class ComponentCreateView(View):
parent = get_object_or_404(self.parent_model, pk=pk) parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(request.POST) form = self.form(parent, request.POST)
if form.is_valid(): if form.is_valid():
new_components = [] new_components = []

View File

@ -262,13 +262,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class VLANGroupFilter(django_filters.FilterSet): class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter( site_id = NullableModelMultipleChoiceFilter(
name='site', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = NullableModelMultipleChoiceFilter(
name='site__slug', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
@ -283,13 +283,13 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
action='search', action='search',
label='Search', label='Search',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = NullableModelMultipleChoiceFilter(
name='site', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = NullableModelMultipleChoiceFilter(
name='site__slug', name='site',
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',

View File

@ -153,7 +153,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
class PrefixForm(BootstrapMixin, CustomFieldForm): class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'})) widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}',
display_field='display_name')) display_field='display_name'))
@ -173,7 +173,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'): elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else: else:
self.fields['vlan'].choices = [] self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
class PrefixFromCSVForm(forms.ModelForm): class PrefixFromCSVForm(forms.ModelForm):
@ -508,7 +508,11 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form): class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
to_field_name='slug',
null_option=(0, 'Global')
)
# #
@ -524,7 +528,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
help_texts = { help_texts = {
'site': "The site at which this VLAN exists", 'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)", 'group': "VLAN group (optional)",
'vid': "Configured VLAN ID", 'vid': "Configured VLAN ID",
'name': "Configured VLAN name", 'name': "Configured VLAN name",
@ -532,7 +536,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
'role': "The primary function of this VLAN", 'role': "The primary function of this VLAN",
} }
widgets = { widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}), 'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -545,11 +549,11 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
elif self.initial.get('site'): elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else: else:
self.fields['group'].choices = [] self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
class VLANFromCSVForm(forms.ModelForm): class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}) error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'}) error_messages={'invalid_choice': 'VLAN group not found.'})
@ -599,7 +603,8 @@ def vlan_status_choices():
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN model = VLAN
q = forms.CharField(required=False, label='Search') q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'Global'))
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None')) null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-21 18:45
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0014_ipaddress_status_add_deprecated'),
]
operations = [
migrations.AlterField(
model_name='vlan',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'),
),
migrations.AlterField(
model_name='vlangroup',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site'),
),
]

View File

@ -485,7 +485,7 @@ class VLANGroup(models.Model):
""" """
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
slug = models.SlugField() slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups') site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
@ -497,6 +497,8 @@ class VLANGroup(models.Model):
verbose_name_plural = 'VLAN groups' verbose_name_plural = 'VLAN groups'
def __str__(self): def __str__(self):
if self.site is None:
return self.name
return u'{} - {}'.format(self.site.name, self.name) return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self): def get_absolute_url(self):
@ -513,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it. or more Prefixes assigned to it.
""" """
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT) site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1), MinValueValidator(1),
@ -551,7 +553,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def to_csv(self): def to_csv(self):
return csv_format([ return csv_format([
self.site.name, self.site.name if self.site else None,
self.group.name if self.group else None, self.group.name if self.group else None,
self.vid, self.vid,
self.name, self.name,

View File

@ -297,9 +297,17 @@ def aggregate(request, pk):
prefix_table.base_columns['pk'].visible = True prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/aggregate.html', { return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate, 'aggregate': aggregate,
'prefix_table': prefix_table, 'prefix_table': prefix_table,
'permissions': permissions,
}) })
@ -425,6 +433,13 @@ def prefix(request, pk):
child_prefix_table.base_columns['pk'].visible = True child_prefix_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/prefix.html', { return render(request, 'ipam/prefix.html', {
'prefix': prefix, 'prefix': prefix,
'aggregate': aggregate, 'aggregate': aggregate,
@ -432,6 +447,7 @@ def prefix(request, pk):
'parent_prefix_table': parent_prefix_table, 'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table, 'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table, 'duplicate_prefix_table': duplicate_prefix_table,
'permissions': permissions,
'return_url': prefix.get_absolute_url(), 'return_url': prefix.get_absolute_url(),
}) })
@ -490,9 +506,17 @@ def prefix_ipaddresses(request, pk):
ip_table.base_columns['pk'].visible = True ip_table.base_columns['pk'].visible = True
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return render(request, 'ipam/prefix_ipaddresses.html', { return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix, 'prefix': prefix,
'ip_table': ip_table, 'ip_table': ip_table,
'permissions': permissions,
}) })

View File

@ -396,6 +396,7 @@
{% if perms.dcim.delete_interface %} {% if perms.dcim.delete_interface %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

View File

@ -3,7 +3,7 @@
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
{% block content %}{{ form.errors }} {% block content %}
<form action="." method="post" class="form form-horizontal"> <form action="." method="post" class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
<div class="row"> <div class="row">

View File

@ -6,14 +6,20 @@
{% endif %} {% endif %}
<td> <td>
<i class="fa fa-fw fa-{{ icon|default:"exchange" }}"></i> <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span> <i class="fa fa-fw fa-{{ icon|default:"exchange" }}"></i> <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span>
{% if iface.lag %}
<span class="label label-primary">{{ iface.lag.name }}</span>
{% endif %}
{% if iface.description %} {% if iface.description %}
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i> <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
{% endif %} {% endif %}
{% if iface.is_lag %}
<br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
{% endif %}
</td> </td>
<td> <td>
<small>{{ iface.mac_address|default:'' }}</small> <small>{{ iface.mac_address|default:'' }}</small>
</td> </td>
{% if not iface.is_physical %} {% if iface.is_virtual %}
<td colspan="2" class="text-muted">Virtual interface</td> <td colspan="2" class="text-muted">Virtual interface</td>
{% elif iface.connection %} {% elif iface.connection %}
{% with iface.connected_interface as connected_iface %} {% with iface.connected_interface as connected_iface %}
@ -48,7 +54,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}
{% if iface.is_physical %} {% if not iface.is_virtual %}
{% if iface.connection %} {% if iface.connection %}
{% if iface.connection.connection_status %} {% if iface.connection.connection_status %}
<a href="#" class="btn btn-warning btn-xs interface-toggle connected" data="{{ iface.connection.pk }}" title="Mark planned"> <a href="#" class="btn btn-warning btn-xs interface-toggle connected" data="{{ iface.connection.pk }}" title="Mark planned">

View File

@ -8,9 +8,11 @@
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li> <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li> {% if vlan.site %}
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
{% endif %}
{% if vlan.group %} {% if vlan.group %}
<li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}&group={{ vlan.group.slug }}">{{ vlan.group.name }}</a></li> <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group.name }}</a></li>
{% endif %} {% endif %}
<li>{{ vlan.name }} ({{ vlan.vid }})</li> <li>{{ vlan.name }} ({{ vlan.vid }})</li>
</ol> </ol>
@ -53,7 +55,13 @@
<table class="table table-hover panel-body attr-table"> <table class="table table-hover panel-body attr-table">
<tr> <tr>
<td>Site</td> <td>Site</td>
<td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td> <td>
{% if vlan.site %}
<a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<td>Group</td> <td>Group</td>

View File

@ -307,11 +307,12 @@ class BulkAddView(View):
if form.is_valid(): if form.is_valid():
# The first field will be used as the pattern # The first field will be used as the pattern
pattern_field = form.fields.keys()[0] field_names = list(form.fields.keys())
pattern_field = field_names[0]
pattern = form.cleaned_data[pattern_field] pattern = form.cleaned_data[pattern_field]
# All other fields will be copied as object attributes # All other fields will be copied as object attributes
kwargs = {k: form.cleaned_data[k] for k in form.fields.keys()[1:]} kwargs = {k: form.cleaned_data[k] for k in field_names[1:]}
new_objs = [] new_objs = []
try: try:
@ -470,7 +471,9 @@ class BulkEditView(View):
return redirect(return_url) return redirect(return_url)
else: else:
form = self.form(self.cls, initial={'pk': pk_list}) initial_data = request.POST.copy()
initial_data['pk'] = pk_list
form = self.form(self.cls, initial=initial_data)
selected_objects = self.cls.objects.filter(pk__in=pk_list) selected_objects = self.cls.objects.filter(pk__in=pk_list)
if not selected_objects: if not selected_objects: