Merge pull request #666 from digitalocean/develop

Release v1.7.0
This commit is contained in:
Jeremy Stretch 2016-11-03 15:12:34 -04:00 committed by GitHub
commit 57ddd5086f
61 changed files with 1003 additions and 904 deletions

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
*.pyc *.pyc
configuration.py /netbox/netbox/configuration.py
/netbox/static
.idea .idea
/*.sh /*.sh
!upgrade.sh !upgrade.sh

View File

@ -98,4 +98,4 @@ dist-switch\d
access-switch\d+,oob-switch\d+ access-switch\d+,oob-switch\d+
``` ```
Note that you can combine multiple regexes onto one line using commas. (Commas can only be used for separating regexes; they will not be processed as part of a regex.) The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.

View File

@ -18,7 +18,7 @@ Download and extract the latest version:
Copy the 'configuration.py' you created when first installing to the new version: Copy the 'configuration.py' you created when first installing to the new version:
``` ```
# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py # cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
``` ```
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:

View File

@ -79,7 +79,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = Rack model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments', 'custom_fields'] 'u_height', 'desc_units', 'comments', 'custom_fields']
class RackNestedSerializer(RackSerializer): class RackNestedSerializer(RackSerializer):
@ -94,7 +94,7 @@ class RackDetailSerializer(RackSerializer):
class Meta(RackSerializer.Meta): class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units'] 'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units']
def get_front_units(self, obj): def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT) units = obj.get_rack_units(face=RACK_FACE_FRONT)

View File

@ -142,7 +142,8 @@ class RackForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = Rack model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments'] fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments']
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
'name': "Organizational rack name", 'name': "Organizational rack name",
@ -178,7 +179,8 @@ class RackFromCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Rack model = Rack
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height'] fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units']
def clean(self): def clean(self):
@ -368,7 +370,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
attrs={'filter-for': 'position'} attrs={'filter-for': 'position'}
)) ))
position = forms.TypedChoiceField(required=False, empty_value=None, position = forms.TypedChoiceField(required=False, empty_value=None,
help_text="For multi-U devices, this is the lowest occupied rack unit.", help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}', widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device')) disabled_indicator='device'))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
@ -582,6 +584,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'platform'] nullable_fields = ['tenant', 'platform']
class DeviceBulkAddComponentForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
name_pattern = ExpandableNameField(label='Name')
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
@ -1012,10 +1026,6 @@ class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description'] fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
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)
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)
@ -1226,15 +1236,12 @@ class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
# IP addresses # IP addresses
# #
class IPAddressForm(forms.ModelForm, BootstrapMixin): class IPAddressForm(BootstrapMixin, CustomFieldForm):
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'interface', 'set_as_primary'] fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
help_texts = {
'address': 'IPv4 or IPv6 address (with mask)'
}
def __init__(self, device, *args, **kwargs): def __init__(self, device, *args, **kwargs):
@ -1251,7 +1258,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
# #
# Interfaces # Modules
# #
class ModuleForm(forms.ModelForm, BootstrapMixin): class ModuleForm(forms.ModelForm, BootstrapMixin):

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-28 15:01
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0019_new_iface_form_factors'),
]
operations = [
migrations.AddField(
model_name='rack',
name='desc_units',
field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'),
),
]

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-31 18:47
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0020_rack_desc_units'),
]
operations = [
migrations.AlterField(
model_name='device',
name='position',
field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [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)']]], [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']]], [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)']]], [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

@ -107,6 +107,8 @@ IFACE_FF_E3 = 4050
# Stacking # Stacking
IFACE_FF_STACKWISE = 5000 IFACE_FF_STACKWISE = 5000
IFACE_FF_STACKWISE_PLUS = 5050 IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150
# Other # Other
IFACE_FF_OTHER = 32767 IFACE_FF_OTHER = 32767
@ -164,6 +166,8 @@ IFACE_FF_CHOICES = [
[ [
[IFACE_FF_STACKWISE, 'Cisco StackWise'], [IFACE_FF_STACKWISE, 'Cisco StackWise'],
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
] ]
], ],
[ [
@ -375,6 +379,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
help_text='Rail-to-rail width') help_text='Rail-to-rail width')
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]) validators=[MinValueValidator(1), MaxValueValidator(100)])
desc_units = models.BooleanField(default=False, verbose_name='Descending units',
help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@ -401,8 +407,11 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
if top_device: if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1 min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height: if self.u_height < min_height:
raise ValidationError("Rack must be at least {}U tall with currently installed devices." raise ValidationError({
.format(min_height)) 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
min_height
)
})
def to_csv(self): def to_csv(self):
return ','.join([ return ','.join([
@ -419,7 +428,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
@property @property
def units(self): def units(self):
return reversed(range(1, self.u_height + 1)) if self.desc_units:
return range(1, self.u_height + 1)
else:
return reversed(range(1, self.u_height + 1))
@property @property
def display_name(self): def display_name(self):
@ -438,7 +450,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
""" """
elevation = OrderedDict() elevation = OrderedDict()
for u in reversed(range(1, self.u_height + 1)): for u in self.units:
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None} elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
# Add devices to rack units list # Add devices to rack units list
@ -476,7 +488,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
""" """
# Gather all devices which consume U space within the rack # Gather all devices which consume U space within the rack
devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude) devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton # Initialize the rack unit skeleton
units = range(1, self.u_height + 1) units = range(1, self.u_height + 1)
@ -506,9 +518,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
""" """
Determine the utilization rate of the rack and return it as a percentage. Determine the utilization rate of the rack and return it as a percentage.
""" """
if self.u_consumed is None: u_available = len(self.get_available_units())
self.u_consumed = 0
u_available = self.u_height - self.u_consumed
return int(float(self.u_height - u_available) / self.u_height * 100) return int(float(self.u_height - u_available) / self.u_height * 100)
@ -596,27 +606,39 @@ class DeviceType(models.Model):
u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required, u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required,
exclude=[d.pk]) exclude=[d.pk])
if d.position not in u_available: if d.position not in u_available:
raise ValidationError("Device {} in rack {} does not have sufficient space to accommodate a height " raise ValidationError({
"of {}U".format(d, d.rack, self.u_height)) 'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
"{}U".format(d, d.rack, self.u_height)
})
if not self.is_console_server and self.cs_port_templates.count(): if not self.is_console_server and self.cs_port_templates.count():
raise ValidationError("Must delete all console server port templates associated with this device before " raise ValidationError({
"declassifying it as a console server.") 'is_console_server': "Must delete all console server port templates associated with this device before "
"declassifying it as a console server."
})
if not self.is_pdu and self.power_outlet_templates.count(): if not self.is_pdu and self.power_outlet_templates.count():
raise ValidationError("Must delete all power outlet templates associated with this device before " raise ValidationError({
"declassifying it as a PDU.") 'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it "
"as a PDU."
})
if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count(): if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count():
raise ValidationError("Must delete all non-management-only interface templates associated with this device " raise ValidationError({
"before declassifying it as a network device.") 'is_network_device': "Must delete all non-management-only interface templates associated with this "
"device before declassifying it as a network device."
})
if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count(): if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
raise ValidationError("Must delete all device bay templates associated with this device before " raise ValidationError({
"declassifying it as a parent device.") 'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
})
if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD: if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
raise ValidationError("Child device types must be 0U.") raise ValidationError({
'u_height': "Child device types must be 0U."
})
@property @property
def is_parent_device(self): def is_parent_device(self):
@ -800,7 +822,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)', verbose_name='Position (U)',
help_text='Number of the lowest U position occupied by the device') help_text='The lowest-numbered unit occupied by the device')
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status') status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
@ -824,29 +846,39 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def clean(self): def clean(self):
# Validate device type assignment
if not hasattr(self, 'device_type'):
raise ValidationError("Must specify device type.")
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and (self.face is not None or self.position):
raise ValidationError("Child device types cannot be assigned a rack face or position.")
# Validate position/face combination # Validate position/face combination
if self.position and self.face is None: if self.position and self.face is None:
raise ValidationError("Must specify rack face with rack position.") raise ValidationError({
'face': "Must specify rack face when defining rack position."
})
# Validate rack space if self.device_type:
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else [] # Child devices cannot be assigned to a rack face/unit
try: if self.device_type.is_child_device and self.face is not None:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, raise ValidationError({
exclude=exclude_list) 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
if self.position and self.position not in available_units: "device."
raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) " })
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)) if self.device_type.is_child_device and self.position:
except Rack.DoesNotExist: raise ValidationError({
pass 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
})
except Rack.DoesNotExist:
pass
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -961,6 +993,9 @@ class ConsolePort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
return ','.join([ return ','.join([
@ -1002,6 +1037,9 @@ class ConsoleServerPort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class PowerPort(models.Model): class PowerPort(models.Model):
""" """
@ -1020,6 +1058,9 @@ class PowerPort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
return ','.join([ return ','.join([
@ -1055,6 +1096,9 @@ class PowerOutlet(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class InterfaceManager(models.Manager): class InterfaceManager(models.Manager):
@ -1091,12 +1135,16 @@ class Interface(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or " raise ValidationError({
"circuit. Disconnect the interface or choose a physical form " 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
"factor."}) "interface or choose a physical form factor."
})
@property @property
def is_physical(self): def is_physical(self):
@ -1147,7 +1195,9 @@ class InterfaceConnection(models.Model):
def clean(self): def clean(self):
if self.interface_a == self.interface_b: if self.interface_a == self.interface_b:
raise ValidationError("Cannot connect an interface to itself") raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
@ -1176,12 +1226,16 @@ class DeviceBay(models.Model):
def __unicode__(self): def __unicode__(self):
return u'{} - {}'.format(self.device.name, self.name) return u'{} - {}'.format(self.device.name, self.name)
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device: if not self.device.device_type.is_parent_device:
raise ValidationError("This type of device ({}) does not support device bays." raise ValidationError("This type of device ({}) does not support device bays.".format(
.format(self.device.device_type)) self.device.device_type
))
# Cannot install a device into itself, obviously # Cannot install a device into itself, obviously
if self.device == self.installed_device: if self.device == self.installed_device:
@ -1208,3 +1262,6 @@ class Module(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return reverse('dcim:device_inventory', args=[self.device.pk])

View File

@ -72,7 +72,7 @@ STATUS_ICON = """
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% utilization_graph record.get_utilization %} {% utilization_graph value %}
""" """
@ -148,13 +148,12 @@ class RackTable(BaseTable):
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role') role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices') devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used') get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed', fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
'utilization') 'get_utilization')
class RackImportTable(BaseTable): class RackImportTable(BaseTable):
@ -196,10 +195,12 @@ class DeviceTypeTable(BaseTable):
manufacturer = tables.Column(verbose_name='Manufacturer') manufacturer = tables.Column(verbose_name='Manufacturer')
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
part_number = tables.Column(verbose_name='Part Number') part_number = tables.Column(verbose_name='Part Number')
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
instance_count = tables.Column(verbose_name='Instances')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceType model = DeviceType
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height') fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
# #
@ -357,7 +358,7 @@ class PowerConnectionTable(BaseTable):
args=[Accessor('power_outlet.device.pk')], verbose_name='PDU') args=[Accessor('power_outlet.device.pk')], verbose_name='PDU')
power_outlet = tables.Column(verbose_name='Outlet') power_outlet = tables.Column(verbose_name='Outlet')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port') name = tables.Column(verbose_name='Power Port')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPort model = PowerPort

View File

@ -49,6 +49,7 @@ class SiteTest(APITestCase):
'type', 'type',
'width', 'width',
'u_height', 'u_height',
'desc_units',
'comments', 'comments',
'custom_fields', 'custom_fields',
] ]
@ -129,6 +130,7 @@ class RackTest(APITestCase):
'type', 'type',
'width', 'width',
'u_height', 'u_height',
'desc_units',
'comments', 'comments',
'custom_fields', 'custom_fields',
] ]
@ -145,6 +147,7 @@ class RackTest(APITestCase):
'type', 'type',
'width', 'width',
'u_height', 'u_height',
'desc_units',
'comments', 'comments',
'custom_fields', 'custom_fields',
'front_units', 'front_units',

View File

@ -110,38 +110,38 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'), url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'), url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
# Console server ports # Console server ports
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'), url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'), url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'), url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
# Power ports # Power ports
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'), url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'), url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.powerport_delete, name='powerport_delete'), url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
# Power outlets # Power outlets
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'), url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'), url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'), url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
# Device bays # Device bays
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'), url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'), url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'), url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
@ -154,18 +154,18 @@ urlpatterns = [
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Interfaces # Interfaces
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'), url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'), url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'), url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'), url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
# Modules # Modules
url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'), url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'),
url(r'^modules/(?P<pk>\d+)/edit/$', views.module_edit, name='module_edit'), url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
url(r'^modules/(?P<pk>\d+)/delete/$', views.module_delete, name='module_delete'), url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
] ]

View File

@ -1,3 +1,4 @@
from copy import deepcopy
import re import re
from natsort import natsorted from natsort import natsorted
from operator import attrgetter from operator import attrgetter
@ -7,8 +8,8 @@ from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db.models import Count, Sum from django.db import transaction
from django.db.models.functions import Coalesce from django.db.models import Count
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.utils.http import urlencode from django.utils.http import urlencode
@ -181,8 +182,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RackListView(ObjectListView): class RackListView(ObjectListView):
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\ queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
.annotate(device_count=Count('devices', distinct=True), .annotate(device_count=Count('devices', distinct=True))
u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0))
filter = filters.RackFilter filter = filters.RackFilter
filter_form = forms.RackFilterForm filter_form = forms.RackFilterForm
table = tables.RackTable table = tables.RackTable
@ -275,7 +275,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class DeviceTypeListView(ObjectListView): class DeviceTypeListView(ObjectListView):
queryset = DeviceType.objects.select_related('manufacturer') queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter filter = filters.DeviceTypeFilter
filter_form = forms.DeviceTypeFilterForm filter_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable table = tables.DeviceTypeTable
@ -394,7 +394,7 @@ class ComponentTemplateCreateView(View):
if not form.errors: if not form.errors:
self.model.objects.bulk_create(component_templates) self.model.objects.bulk_create(component_templates)
messages.success(request, "Added {} component(s) to {}".format(len(component_templates), devicetype)) messages.success(request, u"Added {} component(s) to {}.".format(len(component_templates), devicetype))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
else: else:
@ -574,7 +574,8 @@ def device(request, pk):
secrets = device.secrets.all() secrets = device.secrets.all()
# Find all IP addresses assigned to this device # Find all IP addresses assigned to this device
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface').order_by('address') ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
.order_by('address')
# Find any related devices for convenient linking in the UI # Find any related devices for convenient linking in the UI
related_devices = [] related_devices = []
@ -687,6 +688,80 @@ def device_lldp_neighbors(request, pk):
}) })
class DeviceBulkAddComponentView(View):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = None
component_cls = None
component_form = None
def get(self):
return redirect('dcim:device_list')
def post(self, request):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for device in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
'device': device.pk,
'name': name,
}
component_data.update(data)
component_form = self.component_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
form.add_error('name_pattern', "Duplicate {} name for {}: {}".format(
self.component_cls._meta.verbose_name, device, name
))
if not form.errors:
self.component_cls.objects.bulk_create(new_components)
messages.success(request, u"Added {} {} to {} devices.".format(
len(new_components), self.component_cls._meta.verbose_name_plural, len(form.cleaned_data['pk'])
))
return redirect('dcim:device_list')
else:
form = self.form(initial={'pk': pk_list})
selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices:
messages.warning(request, u"No devices were selected.")
return redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', {
'form': form,
'component_name': self.component_cls._meta.verbose_name_plural,
'selected_devices': selected_devices,
'cancel_url': reverse('dcim:device_list'),
})
class DeviceBulkAddInterfaceView(DeviceBulkAddComponentView):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = forms.DeviceBulkAddInterfaceForm
component_cls = Interface
component_form = forms.InterfaceForm
# #
# Console ports # Console ports
# #
@ -713,7 +788,7 @@ def consoleport_add(request, pk):
if not form.errors: if not form.errors:
ConsolePort.objects.bulk_create(console_ports) ConsolePort.objects.bulk_create(console_ports)
messages.success(request, "Added {} console port(s) to {}".format(len(console_ports), device)) messages.success(request, u"Added {} console port(s) to {}.".format(len(console_ports), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:consoleport_add', pk=device.pk) return redirect('dcim:consoleport_add', pk=device.pk)
else: else:
@ -722,8 +797,9 @@ def consoleport_add(request, pk):
else: else:
form = forms.ConsolePortCreateForm() form = forms.ConsolePortCreateForm()
return render(request, 'dcim/consoleport_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Console Port',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@ -738,7 +814,7 @@ def consoleport_connect(request, pk):
form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport)
if form.is_valid(): if form.is_valid():
consoleport = form.save() consoleport = form.save()
messages.success(request, "Connected {0} {1} to {2} {3}".format( messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device, consoleport.device,
consoleport.name, consoleport.name,
consoleport.cs_port.device, consoleport.cs_port.device,
@ -765,7 +841,7 @@ def consoleport_disconnect(request, pk):
consoleport = get_object_or_404(ConsolePort, pk=pk) consoleport = get_object_or_404(ConsolePort, pk=pk)
if not consoleport.cs_port: if not consoleport.cs_port:
messages.warning(request, "Cannot disconnect console port {0}: It is not connected to anything" messages.warning(request, u"Cannot disconnect console port {}: It is not connected to anything."
.format(consoleport)) .format(consoleport))
return redirect('dcim:device', pk=consoleport.device.pk) return redirect('dcim:device', pk=consoleport.device.pk)
@ -775,7 +851,7 @@ def consoleport_disconnect(request, pk):
consoleport.cs_port = None consoleport.cs_port = None
consoleport.connection_status = None consoleport.connection_status = None
consoleport.save() consoleport.save()
messages.success(request, "Console port {0} has been disconnected".format(consoleport)) messages.success(request, u"Console port {} has been disconnected.".format(consoleport))
return redirect('dcim:device', pk=consoleport.device.pk) return redirect('dcim:device', pk=consoleport.device.pk)
else: else:
@ -788,49 +864,15 @@ def consoleport_disconnect(request, pk):
}) })
@permission_required('dcim.change_consoleport') class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
def consoleport_edit(request, pk): permission_required = 'dcim.change_consoleport'
model = ConsolePort
consoleport = get_object_or_404(ConsolePort, pk=pk) form_class = forms.ConsolePortForm
if request.method == 'POST':
form = forms.ConsolePortForm(request.POST, instance=consoleport)
if form.is_valid():
consoleport = form.save()
messages.success(request, "Modified {0} {1}".format(consoleport.device.name, consoleport.name))
return redirect('dcim:device', pk=consoleport.device.pk)
else:
form = forms.ConsolePortForm(instance=consoleport)
return render(request, 'dcim/consoleport_edit.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
@permission_required('dcim.delete_consoleport') class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def consoleport_delete(request, pk): permission_required = 'dcim.delete_consoleport'
model = ConsolePort
consoleport = get_object_or_404(ConsolePort, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
consoleport.delete()
messages.success(request, "Console port {0} has been deleted from {1}".format(consoleport,
consoleport.device))
return redirect('dcim:device', pk=consoleport.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/consoleport_delete.html', {
'consoleport': consoleport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
})
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -873,7 +915,7 @@ def consoleserverport_add(request, pk):
if not form.errors: if not form.errors:
ConsoleServerPort.objects.bulk_create(cs_ports) ConsoleServerPort.objects.bulk_create(cs_ports)
messages.success(request, "Added {} console server port(s) to {}".format(len(cs_ports), device)) messages.success(request, u"Added {} console server port(s) to {}.".format(len(cs_ports), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:consoleserverport_add', pk=device.pk) return redirect('dcim:consoleserverport_add', pk=device.pk)
else: else:
@ -882,8 +924,9 @@ def consoleserverport_add(request, pk):
else: else:
form = forms.ConsoleServerPortCreateForm() form = forms.ConsoleServerPortCreateForm()
return render(request, 'dcim/consoleserverport_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Console Server Port',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@ -901,7 +944,7 @@ def consoleserverport_connect(request, pk):
consoleport.cs_port = consoleserverport consoleport.cs_port = consoleserverport
consoleport.connection_status = form.cleaned_data['connection_status'] consoleport.connection_status = form.cleaned_data['connection_status']
consoleport.save() consoleport.save()
messages.success(request, "Connected {0} {1} to {2} {3}".format( messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device, consoleport.device,
consoleport.name, consoleport.name,
consoleserverport.device, consoleserverport.device,
@ -925,7 +968,7 @@ def consoleserverport_disconnect(request, pk):
consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
if not hasattr(consoleserverport, 'connected_console'): if not hasattr(consoleserverport, 'connected_console'):
messages.warning(request, "Cannot disconnect console server port {0}: Nothing is connected to it" messages.warning(request, u"Cannot disconnect console server port {}: Nothing is connected to it."
.format(consoleserverport)) .format(consoleserverport))
return redirect('dcim:device', pk=consoleserverport.device.pk) return redirect('dcim:device', pk=consoleserverport.device.pk)
@ -936,7 +979,7 @@ def consoleserverport_disconnect(request, pk):
consoleport.cs_port = None consoleport.cs_port = None
consoleport.connection_status = None consoleport.connection_status = None
consoleport.save() consoleport.save()
messages.success(request, "Console server port {0} has been disconnected".format(consoleserverport)) messages.success(request, u"Console server port {} has been disconnected.".format(consoleserverport))
return redirect('dcim:device', pk=consoleserverport.device.pk) return redirect('dcim:device', pk=consoleserverport.device.pk)
else: else:
@ -949,49 +992,15 @@ def consoleserverport_disconnect(request, pk):
}) })
@permission_required('dcim.change_consoleserverport') class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
def consoleserverport_edit(request, pk): permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) form_class = forms.ConsoleServerPortForm
if request.method == 'POST':
form = forms.ConsoleServerPortForm(request.POST, instance=consoleserverport)
if form.is_valid():
consoleserverport = form.save()
messages.success(request, "Modified {0} {1}".format(consoleserverport.device.name, consoleserverport.name))
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
form = forms.ConsoleServerPortForm(instance=consoleserverport)
return render(request, 'dcim/consoleserverport_edit.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
@permission_required('dcim.delete_consoleserverport') class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def consoleserverport_delete(request, pk): permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort
consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
consoleserverport.delete()
messages.success(request, "Console server port {0} has been deleted from {1}"
.format(consoleserverport, consoleserverport.device))
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/consoleserverport_delete.html', {
'consoleserverport': consoleserverport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
})
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -1026,7 +1035,7 @@ def powerport_add(request, pk):
if not form.errors: if not form.errors:
PowerPort.objects.bulk_create(power_ports) PowerPort.objects.bulk_create(power_ports)
messages.success(request, "Added {} power port(s) to {}".format(len(power_ports), device)) messages.success(request, u"Added {} power port(s) to {}.".format(len(power_ports), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:powerport_add', pk=device.pk) return redirect('dcim:powerport_add', pk=device.pk)
else: else:
@ -1035,8 +1044,9 @@ def powerport_add(request, pk):
else: else:
form = forms.PowerPortCreateForm() form = forms.PowerPortCreateForm()
return render(request, 'dcim/powerport_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Power Port',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@ -1051,7 +1061,7 @@ def powerport_connect(request, pk):
form = forms.PowerPortConnectionForm(request.POST, instance=powerport) form = forms.PowerPortConnectionForm(request.POST, instance=powerport)
if form.is_valid(): if form.is_valid():
powerport = form.save() powerport = form.save()
messages.success(request, "Connected {0} {1} to {2} {3}".format( messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device, powerport.device,
powerport.name, powerport.name,
powerport.power_outlet.device, powerport.power_outlet.device,
@ -1078,7 +1088,7 @@ def powerport_disconnect(request, pk):
powerport = get_object_or_404(PowerPort, pk=pk) powerport = get_object_or_404(PowerPort, pk=pk)
if not powerport.power_outlet: if not powerport.power_outlet:
messages.warning(request, "Cannot disconnect power port {0}: It is not connected to an outlet" messages.warning(request, u"Cannot disconnect power port {}: It is not connected to an outlet."
.format(powerport)) .format(powerport))
return redirect('dcim:device', pk=powerport.device.pk) return redirect('dcim:device', pk=powerport.device.pk)
@ -1088,7 +1098,7 @@ def powerport_disconnect(request, pk):
powerport.power_outlet = None powerport.power_outlet = None
powerport.connection_status = None powerport.connection_status = None
powerport.save() powerport.save()
messages.success(request, "Power port {0} has been disconnected".format(powerport)) messages.success(request, u"Power port {} has been disconnected.".format(powerport))
return redirect('dcim:device', pk=powerport.device.pk) return redirect('dcim:device', pk=powerport.device.pk)
else: else:
@ -1101,48 +1111,15 @@ def powerport_disconnect(request, pk):
}) })
@permission_required('dcim.change_powerport') class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
def powerport_edit(request, pk): permission_required = 'dcim.change_powerport'
model = PowerPort
powerport = get_object_or_404(PowerPort, pk=pk) form_class = forms.PowerPortForm
if request.method == 'POST':
form = forms.PowerPortForm(request.POST, instance=powerport)
if form.is_valid():
powerport = form.save()
messages.success(request, "Modified {0} power port {1}".format(powerport.device.name, powerport.name))
return redirect('dcim:device', pk=powerport.device.pk)
else:
form = forms.PowerPortForm(instance=powerport)
return render(request, 'dcim/powerport_edit.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
@permission_required('dcim.delete_powerport') class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def powerport_delete(request, pk): permission_required = 'dcim.delete_powerport'
model = PowerPort
powerport = get_object_or_404(PowerPort, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
powerport.delete()
messages.success(request, "Power port {0} has been deleted from {1}".format(powerport, powerport.device))
return redirect('dcim:device', pk=powerport.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/powerport_delete.html', {
'powerport': powerport,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
})
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -1184,7 +1161,7 @@ def poweroutlet_add(request, pk):
if not form.errors: if not form.errors:
PowerOutlet.objects.bulk_create(power_outlets) PowerOutlet.objects.bulk_create(power_outlets)
messages.success(request, "Added {} power outlet(s) to {}".format(len(power_outlets), device)) messages.success(request, u"Added {} power outlet(s) to {}.".format(len(power_outlets), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:poweroutlet_add', pk=device.pk) return redirect('dcim:poweroutlet_add', pk=device.pk)
else: else:
@ -1193,8 +1170,9 @@ def poweroutlet_add(request, pk):
else: else:
form = forms.PowerOutletCreateForm() form = forms.PowerOutletCreateForm()
return render(request, 'dcim/poweroutlet_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Power Outlet',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@ -1212,7 +1190,7 @@ def poweroutlet_connect(request, pk):
powerport.power_outlet = poweroutlet powerport.power_outlet = poweroutlet
powerport.connection_status = form.cleaned_data['connection_status'] powerport.connection_status = form.cleaned_data['connection_status']
powerport.save() powerport.save()
messages.success(request, "Connected {0} {1} to {2} {3}".format( messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device, powerport.device,
powerport.name, powerport.name,
poweroutlet.device, poweroutlet.device,
@ -1236,7 +1214,7 @@ def poweroutlet_disconnect(request, pk):
poweroutlet = get_object_or_404(PowerOutlet, pk=pk) poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
if not hasattr(poweroutlet, 'connected_port'): if not hasattr(poweroutlet, 'connected_port'):
messages.warning(request, "Cannot disconnect power outlet {0}: Nothing is connected to it".format(poweroutlet)) messages.warning(request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet))
return redirect('dcim:device', pk=poweroutlet.device.pk) return redirect('dcim:device', pk=poweroutlet.device.pk)
if request.method == 'POST': if request.method == 'POST':
@ -1246,7 +1224,7 @@ def poweroutlet_disconnect(request, pk):
powerport.power_outlet = None powerport.power_outlet = None
powerport.connection_status = None powerport.connection_status = None
powerport.save() powerport.save()
messages.success(request, "Power outlet {0} has been disconnected".format(poweroutlet)) messages.success(request, u"Power outlet {} has been disconnected.".format(poweroutlet))
return redirect('dcim:device', pk=poweroutlet.device.pk) return redirect('dcim:device', pk=poweroutlet.device.pk)
else: else:
@ -1259,49 +1237,15 @@ def poweroutlet_disconnect(request, pk):
}) })
@permission_required('dcim.change_poweroutlet') class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
def poweroutlet_edit(request, pk): permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
poweroutlet = get_object_or_404(PowerOutlet, pk=pk) form_class = forms.PowerOutletForm
if request.method == 'POST':
form = forms.PowerOutletForm(request.POST, instance=poweroutlet)
if form.is_valid():
poweroutlet = form.save()
messages.success(request, "Modified {0} power outlet {1}".format(poweroutlet.device.name, poweroutlet.name))
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
form = forms.PowerOutletForm(instance=poweroutlet)
return render(request, 'dcim/poweroutlet_edit.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
@permission_required('dcim.delete_poweroutlet') class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def poweroutlet_delete(request, pk): permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet
poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
poweroutlet.delete()
messages.success(request, "Power outlet {0} has been deleted from {1}".format(poweroutlet,
poweroutlet.device))
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/poweroutlet_delete.html', {
'poweroutlet': poweroutlet,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
})
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -1340,97 +1284,32 @@ def interface_add(request, pk):
if not form.errors: if not form.errors:
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
messages.success(request, "Added {} interface(s) to {}".format(len(interfaces), device)) messages.success(request, u"Added {} interface(s) to {}.".format(len(interfaces), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:interface_add', pk=device.pk) return redirect('dcim:interface_add', pk=device.pk)
else: else:
return redirect('dcim:device', pk=device.pk) return redirect('dcim:device', pk=device.pk)
else: else:
form = forms.InterfaceCreateForm() form = forms.InterfaceCreateForm(initial={'mgmt_only': request.GET.get('mgmt_only')})
return render(request, 'dcim/interface_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Interface',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@permission_required('dcim.change_interface') class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
def interface_edit(request, pk): permission_required = 'dcim.change_interface'
model = Interface
interface = get_object_or_404(Interface, pk=pk) form_class = forms.InterfaceForm
if request.method == 'POST':
form = forms.InterfaceForm(request.POST, instance=interface)
if form.is_valid():
interface = form.save()
messages.success(request, "Modified {0} interface {1}".format(interface.device.name, interface.name))
return redirect('dcim:device', pk=interface.device.pk)
else:
form = forms.InterfaceForm(instance=interface)
return render(request, 'dcim/interface_edit.html', {
'interface': interface,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}),
})
@permission_required('dcim.delete_interface') class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def interface_delete(request, pk): permission_required = 'dcim.delete_interface'
model = Interface
interface = get_object_or_404(Interface, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
interface.delete()
messages.success(request, "Interface {0} has been deleted from {1}".format(interface, interface.device))
return redirect('dcim:device', pk=interface.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/interface_delete.html', {
'interface': interface,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}),
})
class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.add_interface'
cls = Device
form = forms.InterfaceBulkCreateForm
template_name = 'dcim/interface_add_multi.html'
default_redirect_url = 'dcim:device_list'
def update_objects(self, pk_list, form, fields):
selected_devices = Device.objects.filter(pk__in=pk_list)
interfaces = []
for device in selected_devices:
for name in form.cleaned_data['name_pattern']:
iface_form = forms.InterfaceForm({
'device': device.pk,
'name': name,
'mac_address': form.cleaned_data['mac_address'],
'form_factor': form.cleaned_data['form_factor'],
'mgmt_only': form.cleaned_data['mgmt_only'],
'description': form.cleaned_data['description'],
})
if iface_form.is_valid():
interfaces.append(iface_form.save(commit=False))
else:
form.add_error(None, "Duplicate interface {} found for device {}".format(name, device))
if not form.errors:
Interface.objects.bulk_create(interfaces)
messages.success(self.request, "Added {} interfaces to {} devices".format(len(interfaces),
len(selected_devices)))
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
@ -1474,7 +1353,7 @@ def devicebay_add(request, pk):
if not form.errors: if not form.errors:
DeviceBay.objects.bulk_create(device_bays) DeviceBay.objects.bulk_create(device_bays)
messages.success(request, "Added {} device bay(s) to {}".format(len(device_bays), device)) messages.success(request, u"Added {} device bay(s) to {}.".format(len(device_bays), device))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:devicebay_add', pk=device.pk) return redirect('dcim:devicebay_add', pk=device.pk)
else: else:
@ -1483,55 +1362,23 @@ def devicebay_add(request, pk):
else: else:
form = forms.DeviceBayCreateForm() form = forms.DeviceBayCreateForm()
return render(request, 'dcim/devicebay_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Device Bay',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
}) })
@permission_required('dcim.change_devicebay') class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
def devicebay_edit(request, pk): permission_required = 'dcim.change_devicebay'
model = DeviceBay
devicebay = get_object_or_404(DeviceBay, pk=pk) form_class = forms.DeviceBayForm
if request.method == 'POST':
form = forms.DeviceBayForm(request.POST, instance=devicebay)
if form.is_valid():
devicebay = form.save()
messages.success(request, "Modified {} bay {}".format(devicebay.device.name, devicebay.name))
return redirect('dcim:device', pk=devicebay.device.pk)
else:
form = forms.DeviceBayForm(instance=devicebay)
return render(request, 'dcim/devicebay_edit.html', {
'devicebay': devicebay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
})
@permission_required('dcim.delete_devicebay') class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def devicebay_delete(request, pk): permission_required = 'dcim.delete_devicebay'
model = DeviceBay
devicebay = get_object_or_404(DeviceBay, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
devicebay.delete()
messages.success(request, "Device bay {} has been deleted from {}".format(devicebay, devicebay.device))
return redirect('dcim:device', pk=devicebay.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/devicebay_delete.html', {
'devicebay': devicebay,
'form': form,
'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}),
})
@permission_required('dcim.change_devicebay') @permission_required('dcim.change_devicebay')
@ -1547,7 +1394,7 @@ def devicebay_populate(request, pk):
device_bay.save() device_bay.save()
if not form.errors: if not form.errors:
messages.success(request, "Added {} to {}".format(device_bay.installed_device, device_bay)) messages.success(request, u"Added {} to {}.".format(device_bay.installed_device, device_bay))
return redirect('dcim:device', pk=device_bay.device.pk) return redirect('dcim:device', pk=device_bay.device.pk)
else: else:
@ -1571,7 +1418,7 @@ def devicebay_depopulate(request, pk):
removed_device = device_bay.installed_device removed_device = device_bay.installed_device
device_bay.installed_device = None device_bay.installed_device = None
device_bay.save() device_bay.save()
messages.success(request, "{} has been removed from {}".format(removed_device, device_bay)) messages.success(request, u"{} has been removed from {}.".format(removed_device, device_bay))
return redirect('dcim:device', pk=device_bay.device.pk) return redirect('dcim:device', pk=device_bay.device.pk)
else: else:
@ -1603,7 +1450,7 @@ def interfaceconnection_add(request, pk):
form = forms.InterfaceConnectionForm(device, request.POST) form = forms.InterfaceConnectionForm(device, request.POST)
if form.is_valid(): if form.is_valid():
interfaceconnection = form.save() interfaceconnection = form.save()
messages.success(request, "Connected {0} {1} to {2} {3}".format( messages.success(request, u"Connected {} {} to {} {}.".format(
interfaceconnection.interface_a.device, interfaceconnection.interface_a.device,
interfaceconnection.interface_a, interfaceconnection.interface_a,
interfaceconnection.interface_b.device, interfaceconnection.interface_b.device,
@ -1643,7 +1490,7 @@ def interfaceconnection_delete(request, pk):
form = forms.InterfaceConnectionDeletionForm(request.POST) form = forms.InterfaceConnectionDeletionForm(request.POST)
if form.is_valid(): if form.is_valid():
interfaceconnection.delete() interfaceconnection.delete()
messages.success(request, "Deleted the connection between {0} {1} and {2} {3}".format( messages.success(request, u"Deleted the connection between {} {} and {} {}.".format(
interfaceconnection.interface_a.device, interfaceconnection.interface_a.device,
interfaceconnection.interface_a, interfaceconnection.interface_a,
interfaceconnection.interface_b.device, interfaceconnection.interface_b.device,
@ -1715,7 +1562,7 @@ class InterfaceConnectionsListView(ObjectListView):
# IP addresses # IP addresses
# #
@permission_required('ipam.add_ipaddress') @permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
def ipaddress_assign(request, pk): def ipaddress_assign(request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
@ -1727,8 +1574,8 @@ def ipaddress_assign(request, pk):
ipaddress = form.save(commit=False) ipaddress = form.save(commit=False)
ipaddress.interface = form.cleaned_data['interface'] ipaddress.interface = form.cleaned_data['interface']
ipaddress.save() ipaddress.save()
messages.success(request, "Added new IP address {0} to interface {1}".format(ipaddress, form.save_custom_fields()
ipaddress.interface)) messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']: if form.cleaned_data['set_as_primary']:
if ipaddress.family == 4: if ipaddress.family == 4:
@ -1767,7 +1614,7 @@ def module_add(request, pk):
module = form.save(commit=False) module = form.save(commit=False)
module.device = device module.device = device
module.save() module.save()
messages.success(request, "Added module {} to {}".format(module.name, module.device.name)) messages.success(request, u"Added module {} to {}".format(module.name, module.device.name))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:module_add', pk=module.device.pk) return redirect('dcim:module_add', pk=module.device.pk)
else: else:
@ -1776,52 +1623,20 @@ def module_add(request, pk):
else: else:
form = forms.ModuleForm() form = forms.ModuleForm()
return render(request, 'dcim/module_edit.html', { return render(request, 'dcim/device_component_add.html', {
'device': device, 'device': device,
'component_type': 'Module',
'form': form, 'form': form,
'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}), 'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}),
}) })
@permission_required('dcim.change_module') class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
def module_edit(request, pk): permission_required = 'dcim.change_module'
model = Module
module = get_object_or_404(Module, pk=pk) form_class = forms.ModuleForm
if request.method == 'POST':
form = forms.ModuleForm(request.POST, instance=module)
if form.is_valid():
module = form.save()
messages.success(request, "Modified {} module {}".format(module.device.name, module.name))
return redirect('dcim:device_inventory', pk=module.device.pk)
else:
form = forms.ModuleForm(instance=module)
return render(request, 'dcim/module_edit.html', {
'module': module,
'form': form,
'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': module.device.pk}),
})
@permission_required('dcim.delete_module') class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def module_delete(request, pk): permission_required = 'dcim.delete_module'
model = Module
module = get_object_or_404(Module, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
module.delete()
messages.success(request, "Module {} has been deleted from {}".format(module, module.device))
return redirect('dcim:device_inventory', pk=module.device.pk)
else:
form = ConfirmationForm()
return render(request, 'dcim/module_delete.html', {
'module': module,
'form': form,
'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': module.device.pk}),
})

View File

@ -80,7 +80,7 @@ class TopologyMapView(APIView):
# Add each device to the graph # Add each device to the graph
devices = [] devices = []
for query in device_set.split(','): for query in device_set.split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query) devices += Device.objects.filter(name__regex=query)
for d in devices: for d in devices:
subgraph.node(d.name) subgraph.node(d.name)
@ -94,7 +94,7 @@ class TopologyMapView(APIView):
# Compile list of all devices # Compile list of all devices
device_superset = Q() device_superset = Q()
for device_set in tmap.device_sets: for device_set in tmap.device_sets:
for query in device_set.split(','): for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query) device_superset = device_superset | Q(name__regex=query)
# Add all connections to the graph # Add all connections to the graph

View File

@ -142,7 +142,6 @@ class CustomFieldBulkEditForm(BulkEditForm):
self.fields[name] = field self.fields[name] = field
# Annotate this as a custom field # Annotate this as a custom field
self.custom_fields.append(name) self.custom_fields.append(name)
print(self.nullable_fields)
class CustomFieldFilterForm(forms.Form): class CustomFieldFilterForm(forms.Form):

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-11-03 18:33
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):
dependencies = [
('extras', '0003_exporttemplate_add_description'),
]
operations = [
migrations.AlterField(
model_name='topologymap',
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

@ -268,10 +268,11 @@ class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True) site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions," device_patterns = models.TextField(
"one per line. Each line will result in a new tier of the drawing. " help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"Separate multiple regexes on a line using commas. Devices will be " "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
"rendered in the order they are defined.") "Devices will be rendered in the order they are defined."
)
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
class Meta: class Meta:

View File

@ -159,8 +159,8 @@ class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside', fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
'custom_fields'] 'nat_outside', 'custom_fields']
class IPAddressNestedSerializer(IPAddressSerializer): class IPAddressNestedSerializer(IPAddressSerializer):

View File

@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['q', 'family', 'device_id', 'device', 'interface_id'] fields = ['q', 'family', 'status', 'device_id', 'device', 'interface_id']
def search(self, queryset, value): def search(self, queryset, value):
qs_filter = Q(description__icontains=value) qs_filter = Q(description__icontains=value)

View File

@ -1,20 +1,19 @@
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 from dcim.models import Site, Rack, Device, Interface
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 (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice,
) )
from .models import ( from .models import (
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup,
VLAN_STATUS_CHOICES, VRF,
) )
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
IP_FAMILY_CHOICES = [ IP_FAMILY_CHOICES = [
('', 'All'), ('', 'All'),
(4, 'IPv4'), (4, 'IPv4'),
@ -173,16 +172,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
else: else:
self.fields['vlan'].choices = [] self.fields['vlan'].choices = []
def clean_prefix(self):
prefix = self.cleaned_data['prefix']
if prefix.version == 4 and prefix.prefixlen == 32:
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
"addresses instead.")
elif prefix.version == 6 and prefix.prefixlen == 128:
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
"addresses instead.")
return prefix
class PrefixFromCSVForm(forms.ModelForm): class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@ -248,7 +237,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False) status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
@ -295,16 +284,12 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch( livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address') query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
) )
nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)',
widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'))
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description'] fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
help_texts = { widgets = {
'address': "IPv4 or IPv6 address and mask", 'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
'vrf': "VRF (if applicable)",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -347,11 +332,35 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
self.fields['nat_inside'].choices = [] self.fields['nat_inside'].choices = []
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
def __init__(self, *args, **kwargs):
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
self.fields['rack'].choices = []
self.fields['device'].choices = []
self.fields['interface'].choices = []
class IPAddressFromCSVForm(forms.ModelForm): class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'}) error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}) error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES])
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'}) error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False) interface_name = forms.CharField(required=False)
@ -359,7 +368,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description'] fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description']
def clean(self): def clean(self):
@ -406,12 +415,20 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_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 = ['vrf', 'tenant', 'description'] nullable_fields = ['vrf', 'tenant', 'description']
def ipaddress_status_choices():
status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
@ -422,6 +439,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='VRF', null_option=(0, 'Global')) label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None')) to_field_name='slug', null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
# #
@ -479,7 +497,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
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(), to_field_name='name',
error_messages={'invalid_choice': 'Device 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.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
@ -510,7 +528,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False) status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-10-21 15:44
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0008_prefix_change_order'),
]
operations = [
migrations.AddField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
),
]

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-11-01 17:46
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import ipam.fields
class Migration(migrations.Migration):
dependencies = [
('ipam', '0009_ipaddress_add_status'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='address',
field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'),
),
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'),
),
]

View File

@ -29,6 +29,12 @@ PREFIX_STATUS_CHOICES = (
(3, 'Deprecated') (3, 'Deprecated')
) )
IPADDRESS_STATUS_CHOICES = (
(1, 'Active'),
(2, 'Reserved'),
(5, 'DHCP')
)
VLAN_STATUS_CHOICES = ( VLAN_STATUS_CHOICES = (
(1, 'Active'), (1, 'Active'),
(2, 'Reserved'), (2, 'Reserved'),
@ -40,6 +46,8 @@ STATUS_CHOICE_CLASSES = {
1: 'primary', 1: 'primary',
2: 'info', 2: 'info',
3: 'danger', 3: 'danger',
4: 'warning',
5: 'success',
} }
@ -131,16 +139,22 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
if self.pk: if self.pk:
covering_aggregates = covering_aggregates.exclude(pk=self.pk) covering_aggregates = covering_aggregates.exclude(pk=self.pk)
if covering_aggregates: if covering_aggregates:
raise ValidationError("{} is already covered by an existing aggregate ({})" raise ValidationError({
.format(self.prefix, covering_aggregates[0])) 'prefix': "Aggregates cannot overlap. {} is already covered by an existing aggregate ({}).".format(
self.prefix, covering_aggregates[0]
)
})
# Ensure that the aggregate being added does not cover an existing aggregate # Ensure that the aggregate being added does not cover an existing aggregate
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix)) covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
if self.pk: if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk) covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates: if covered_aggregates:
raise ValidationError("{} overlaps with an existing aggregate ({})" raise ValidationError({
.format(self.prefix, covered_aggregates[0])) 'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format(
self.prefix, covered_aggregates[0]
)
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.prefix: if self.prefix:
@ -260,14 +274,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
return reverse('ipam:prefix', args=[self.pk]) return reverse('ipam:prefix', args=[self.pk])
def clean(self): def clean(self):
# Disallow host masks # Disallow host masks
if self.prefix: if self.prefix:
if self.prefix.version == 4 and self.prefix.prefixlen == 32: if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses " raise ValidationError({
"instead.") 'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
})
elif self.prefix.version == 6 and self.prefix.prefixlen == 128: elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses " raise ValidationError({
"instead.") 'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.prefix: if self.prefix:
@ -329,14 +346,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
""" """
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
address = IPAddressField() address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)")
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VRF') verbose_name='VRF')
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1)
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
null=True) null=True)
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
null=True, verbose_name='NAT IP (inside)') null=True, verbose_name='NAT (Inside)',
help_text="The IP for which this address is the \"outside\" IP")
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@ -360,13 +379,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\ duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk) .exclude(pk=self.pk)
if duplicate_ips: if duplicate_ips:
raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf, raise ValidationError({
duplicate_ips.first())) 'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first())
})
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE: elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\ duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk) .exclude(pk=self.pk)
if duplicate_ips: if duplicate_ips:
raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first())) raise ValidationError({
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.address: if self.address:
@ -387,6 +409,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
str(self.address), str(self.address),
self.vrf.rd if self.vrf else '', self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '', self.tenant.name if self.tenant else '',
self.get_status_display(),
self.device.identifier if self.device else '', self.device.identifier if self.device else '',
self.interface.name if self.interface else '', self.interface.name if self.interface else '',
'True' if is_primary else '', 'True' if is_primary else '',
@ -399,6 +422,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device return self.interface.device
return None return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
class VLANGroup(models.Model): class VLANGroup(models.Model):
""" """
@ -465,7 +491,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
# Validate VLAN group # Validate VLAN group
if self.group and self.group.site != self.site: if self.group and self.group.site != self.site:
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site)) raise ValidationError({
'group': "VLAN group must belong to the assigned site ({}).".format(self.site)
})
def to_csv(self): def to_csv(self):
return ','.join([ return ','.join([

View File

@ -14,7 +14,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% utilization_graph record.get_utilization %} {% utilization_graph value %}
""" """
ROLE_ACTIONS = """ ROLE_ACTIONS = """
@ -125,13 +125,13 @@ class AggregateTable(BaseTable):
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate') prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
rir = tables.Column(verbose_name='RIR') rir = tables.Column(verbose_name='RIR')
child_count = tables.Column(verbose_name='Prefixes') child_count = tables.Column(verbose_name='Prefixes')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(orderable=False, verbose_name='Description') description = tables.Column(orderable=False, verbose_name='Description')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Aggregate model = Aggregate
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description') fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
# #
@ -193,6 +193,7 @@ class PrefixBriefTable(BaseTable):
class IPAddressTable(BaseTable): class IPAddressTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
@ -202,7 +203,7 @@ class IPAddressTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description') fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
} }

View File

@ -56,6 +56,8 @@ urlpatterns = [
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'), url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups # VLAN groups

View File

@ -1,11 +1,15 @@
import netaddr import netaddr
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, redirect, render
from dcim.models import Device from dcim.models import Device
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@ -446,6 +450,73 @@ def ipaddress(request, pk):
}) })
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_assign(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = forms.IPAddressAssignForm(request.POST)
if form.is_valid():
interface = form.cleaned_data['interface']
ipaddress.interface = interface
ipaddress.save()
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
device = interface.device
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_remove(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
device = ipaddress.interface.device
ipaddress.interface = None
ipaddress.save()
messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
if device.primary_ip4 == ipaddress.pk:
device.primary_ip4 = None
device.save()
elif device.primary_ip6 == ipaddress.pk:
device.primary_ip6 = None
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/ipaddress_unassign.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView): class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_ipaddress' permission_required = 'ipam.change_ipaddress'
model = IPAddress model = IPAddress

View File

@ -12,7 +12,7 @@ except ImportError:
"the documentation.") "the documentation.")
VERSION = '1.6.3' VERSION = '1.7.0'
# Import local configuration # Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

View File

@ -1,9 +1,8 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from django.views.defaults import page_not_found
from views import home, trigger_500, handle_500 from views import home, handle_500, trigger_500
from users.views import login, logout from users.views import login, logout
@ -36,7 +35,6 @@ _patterns = [
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Error testing # Error testing
url(r'^404/$', page_not_found),
url(r'^500/$', trigger_500), url(r'^500/$', trigger_500),
# Admin # Admin

View File

@ -47,16 +47,20 @@ def home(request):
}) })
def trigger_500(request):
"""Hot-wired method of triggering a server error to test reporting."""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")
def handle_500(request): def handle_500(request):
"""Custom server error handler""" """
Custom server error handler
"""
type_, error, traceback = sys.exc_info() type_, error, traceback = sys.exc_info()
return render(request, '500.html', { return render(request, '500.html', {
'exception': str(type_), 'exception': str(type_),
'error': error, 'error': error,
}, status=500) }, status=500)
def trigger_500(request):
"""
Hot-wired method of triggering a server error to test reporting
"""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")

View File

@ -34,7 +34,7 @@ class UserKeyAdmin(admin.ModelAdmin):
try: try:
my_userkey = UserKey.objects.get(user=request.user) my_userkey = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist: except UserKey.DoesNotExist:
messages.error(request, "You do not have an active User Key.") messages.error(request, u"You do not have an active User Key.")
return redirect('/admin/secrets/userkey/') return redirect('/admin/secrets/userkey/')
if 'activate' in request.POST: if 'activate' in request.POST:
@ -46,7 +46,7 @@ class UserKeyAdmin(admin.ModelAdmin):
uk.activate(master_key) uk.activate(master_key)
return redirect('/admin/secrets/userkey/') return redirect('/admin/secrets/userkey/')
except ValueError: except ValueError:
messages.error(request, "Invalid private key provided. Unable to retrieve master key.") messages.error(request, u"Invalid private key provided. Unable to retrieve master key.")
else: else:
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})

View File

@ -14,10 +14,10 @@ def userkey_required():
try: try:
uk = UserKey.objects.get(user=request.user) uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist: except UserKey.DoesNotExist:
messages.warning(request, "This operation requires an active user key, but you don't have one.") messages.warning(request, u"This operation requires an active user key, but you don't have one.")
return redirect('users:userkey') return redirect('users:userkey')
if not uk.is_active(): if not uk.is_active():
messages.warning(request, "This operation is not available. Your user key has not been activated.") messages.warning(request, u"This operation is not available. Your user key has not been activated.")
return redirect('users:userkey') return redirect('users:userkey')
return view(request, *args, **kwargs) return view(request, *args, **kwargs)
return wrapped_view return wrapped_view

View File

@ -49,22 +49,23 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
class SecretForm(forms.ModelForm, BootstrapMixin): class SecretForm(forms.ModelForm, BootstrapMixin):
private_key = forms.CharField(required=False, widget=forms.HiddenInput()) private_key = forms.CharField(required=False, widget=forms.HiddenInput())
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
widget=forms.TextInput(attrs={'class': 'requires-private-key'})) widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)') plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
widget=forms.PasswordInput())
class Meta: class Meta:
model = Secret model = Secret
fields = ['role', 'name', 'plaintext', 'plaintext2'] fields = ['role', 'name', 'plaintext', 'plaintext2']
def clean(self): def clean(self):
if self.cleaned_data['plaintext']: if self.cleaned_data['plaintext']:
validate_rsa_key(self.cleaned_data['private_key']) validate_rsa_key(self.cleaned_data['private_key'])
def clean_plaintext2(self): if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
plaintext = self.cleaned_data['plaintext'] raise forms.ValidationError({
plaintext2 = self.cleaned_data['plaintext2'] 'plaintext2': "The two given plaintext values do not match. Please check your input."
if plaintext != plaintext2: })
raise forms.ValidationError("The two given plaintext values do not match. Please check your input.")
class SecretFromCSVForm(forms.ModelForm): class SecretFromCSVForm(forms.ModelForm):

View File

@ -81,24 +81,34 @@ class UserKey(CreatedUpdatedModel):
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
# Validate the public key format and length.
if self.public_key: if self.public_key:
# Validate the public key format
try: try:
pubkey = RSA.importKey(self.public_key) pubkey = RSA.importKey(self.public_key)
except ValueError: except ValueError:
raise ValidationError("Invalid RSA key format.") raise ValidationError({
'public_key': "Invalid RSA key format."
})
except: except:
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're " raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
"uploading a valid RSA public key in PEM format (no SSH/PGP).") "uploading a valid RSA public key in PEM format (no SSH/PGP).")
# key.size() returns 1 less than the key modulus
pubkey_length = pubkey.size() + 1 # Validate the public key length
pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE: if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
raise ValidationError("Insufficient key length. Keys must be at least {} bits long." raise ValidationError({
.format(settings.SECRETS_MIN_PUBKEY_SIZE)) 'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
settings.SECRETS_MIN_PUBKEY_SIZE
)
})
# We can't use keys bigger than our master_key_cipher field can hold # We can't use keys bigger than our master_key_cipher field can hold
if pubkey_length > 4096: if pubkey_length > 4096:
raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits." raise ValidationError({
.format(pubkey_length)) 'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
pubkey_length
)
})
super(UserKey, self).clean() super(UserKey, self).clean()

View File

@ -90,7 +90,7 @@ def secret_add(request, pk):
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save() secret.save()
messages.success(request, "Added new secret: {0}".format(secret)) messages.success(request, u"Added new secret: {}.".format(secret))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:device_addsecret', pk=device.pk) return redirect('dcim:device_addsecret', pk=device.pk)
else: else:
@ -135,7 +135,7 @@ def secret_edit(request, pk):
else: else:
secret = form.save() secret = form.save()
messages.success(request, "Modified secret {0}".format(secret)) messages.success(request, u"Modified secret {}.".format(secret))
return redirect('secrets:secret', pk=secret.pk) return redirect('secrets:secret', pk=secret.pk)
else: else:
@ -180,7 +180,7 @@ def secret_import(request):
new_secrets.append(secret) new_secrets.append(secret)
table = tables.SecretTable(new_secrets) table = tables.SecretTable(new_secrets)
messages.success(request, "Imported {} new secrets".format(len(new_secrets))) messages.success(request, u"Imported {} new secrets.".format(len(new_secrets)))
return render(request, 'import_success.html', { return render(request, 'import_success.html', {
'table': table, 'table': table,

19
netbox/templates/404.html Normal file
View File

@ -0,0 +1,19 @@
{% extends '_base.html' %}
{% block content %}
<div class="row" style="margin-top: 150px;">
<div class="col-sm-4 col-sm-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="glyphicon glyphicon-warning-sign"></i> Page Not Found</strong>
</div>
<div class="panel-body">
The requested page does not exist.
</div>
<div class="panel-footer text-right">
<a href="{% url 'home' %}" class="btn btn-xs btn-primary">Home Page</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -131,6 +131,21 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>IP Addressing</td>
<td>
{% if circuit.interface %}
{% for ip in circuit.interface.ip_addresses.all %}
{% if not forloop.first %}<br />{% endif %}
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Cross-Connect</td> <td>Cross-Connect</td>
<td> <td>

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if consoleport.pk %}Editing {{ consoleport.device }} {{ consoleport }}{% else %}Add a Console Port ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if consoleport.pk %}
<strong>Editing {{ consoleport }}</strong>
{% else %}
<strong>Add a Console Port</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if consoleport %}{{ consoleport.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if consoleport.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if consoleserverport.pk %}Editing {{ consoleserverport.device }} {{ consoleserverport }}{% else %}Add a Console Server Port ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if consoleserverport.pk %}
<strong>Editing {{ consoleserverport }}</strong>
{% else %}
<strong>Add a Console Server Port</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if consoleserverport %}{{ consoleserverport.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if consoleserverport.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block content %}
<h1>Add {{ component_name|title }}</h1>
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% endif %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading"><strong>Selected Devices</strong></div>
<table class="panel-body table table-hover">
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
</tr>
{% for device in selected_devices %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type }}</td>
<td>{{ device.device_role }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="col-md-5">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>{{ component_name|title }} to Add</strong></div>
<div class="panel-body">
{% for field in form.visible_fields %}
{% render_field field %}
{% endfor %}
</div>
</div>
<div class="form-group text-right">
<div class="col-md-12">
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}{% endblock %} {% block title %}Create {{ component_type }} ({{ device }}){% endblock %}
{% block content %} {% block content %}
<form action="." method="post" class="form form-horizontal"> <form action="." method="post" class="form form-horizontal">
@ -18,13 +18,13 @@
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}</strong> <strong>{{ component_type }}</strong>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Device</label> <label class="col-md-3 control-label required">Device</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{% if module %}{{ module.device }}{% else %}{{ device }}{% endif %}</p> <p class="form-control-static">{{ device }}</p>
</div> </div>
</div> </div>
{% render_form form %} {% render_form form %}
@ -32,12 +32,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-md-9 col-md-offset-3"> <div class="col-md-9 col-md-offset-3">
{% if module.pk %} <button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button> <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a> <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div> </div>
</div> </div>

View File

@ -78,7 +78,7 @@
</tr> </tr>
<tr> <tr>
<td>Position (U)</td> <td>Position (U)</td>
<td>Lowest rack unit occupied by the device (optional)</td> <td>Lowest-numbered rack unit occupied by the device (optional)</td>
<td>21</td> <td>21</td>
</tr> </tr>
<tr> <tr>

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if devicebay.pk %}Editing {{ devicebay.device }} {{ devicebay }}{% else %}Add a Device Bay ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if poweroutlet.pk %}
<strong>Editing {{ devicebay }}</strong>
{% else %}
<strong>Add a Device Bay</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if devicebay %}{{ devicebay.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if devicebay.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -2,6 +2,9 @@
<td> <td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
</td> </td>
<td>
{{ ip.vrf|default:"Global" }}
</td>
<td>{{ ip.interface }}</td> <td>{{ ip.interface }}</td>
<td> <td>
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %} {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}

View File

@ -2,7 +2,7 @@
{% block extra_actions %} {% block extra_actions %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_add_multi' %}" class="btn btn-primary btn-sm"> <button type="submit" name="_edit" formaction="{% url 'dcim:device_bulk_add_interface' %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces
</button> </button>
{% endif %} {% endif %}

View File

@ -1,23 +0,0 @@
{% extends 'utilities/bulk_edit_form.html' %}
{% load form_helpers %}
{% block title %}Add Interfaces{% endblock %}
{% block selected_objects_title %}Selected Devices{% endblock %}
{% block form_title %}Interface(s) to Add{% endblock %}
{% block selected_objects_table %}
<tr>
<th>Device</th>
<th>Type</th>
<th>Role</th>
</tr>
{% for device in selected_objects %}
<tr>
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
<td>{{ device.device_type }}</td>
<td>{{ device.device_role }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if interface.pk %}Editing {{ interface.device }} {{ interface }}{% else %}Add an Interface ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if interface.pk %}
<strong>Editing {{ interface }}</strong>
{% else %}
<strong>Add an Interface</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if interface %}{{ interface.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if interface.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load form_helpers %} {% load form_helpers %}
{% block title %}Add an IP Address{% endblock %} {% block title %}Assign an IP Address{% endblock %}
{% block content %} {% block content %}
<form action="." method="post" class="form form-horizontal"> <form action="." method="post" class="form form-horizontal">
@ -18,10 +18,34 @@
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
Add an IP Address <strong>IP Address</strong>
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% render_form form %} {% render_field form.address %}
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.status %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">Device</label>
<div class="col-md-9">
<p class="form-control-static">{{ device }}</p>
</div>
</div>
{% render_field form.interface %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if poweroutlet.pk %}Editing {{ poweroutlet.device }} {{ poweroutlet }}{% else %}Add a Power Outlet ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if poweroutlet.pk %}
<strong>Editing {{ poweroutlet }}</strong>
{% else %}
<strong>Add a Power Outlet</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if poweroutlet %}{{ poweroutlet.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if poweroutlet.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -1,51 +0,0 @@
{% extends '_base.html' %}
{% load form_helpers %}
{% block title %}{% if powerport.pk %}Editing {{ powerport.device }} {{ powerport }}{% else %}Add a Power Port ({{ device }}){% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
{% if powerport.pk %}
<strong>Editing {{ powerport }}</strong>
{% else %}
<strong>Add a Power Port</strong>
{% endif %}
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label required">Device</label>
<div class="col-md-9">
<p class="form-control-static">{% if powerport %}{{ powerport.device }}{% else %}{{ device }}{% endif %}</p>
</div>
</div>
{% render_form form %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
{% if powerport.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
{% endif %}
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -122,7 +122,7 @@
</tr> </tr>
<tr> <tr>
<td>Height</td> <td>Height</td>
<td>{{ rack.u_height }}U</td> <td>{{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %})</td>
</tr> </tr>
<tr> <tr>
<td>Devices</td> <td>Devices</td>
@ -189,13 +189,13 @@
<div class="rack_header"> <div class="rack_header">
<h4>Front</h4> <h4>Front</h4>
</div> </div>
{% include 'dcim/_rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %} {% include 'dcim/inc/_rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %}
</div> </div>
<div class="col-md-6 col-sm-6 col-xs-12"> <div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header"> <div class="rack_header">
<h4>Rear</h4> <h4>Rear</h4>
</div> </div>
{% include 'dcim/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %} {% include 'dcim/inc/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@
{% render_field form.type %} {% render_field form.type %}
{% render_field form.width %} {% render_field form.width %}
{% render_field form.u_height %} {% render_field form.u_height %}
{% render_field form.desc_units %}
</div> </div>
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}

View File

@ -73,10 +73,15 @@
<td>Height in rack units</td> <td>Height in rack units</td>
<td>42</td> <td>42</td>
</tr> </tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42</pre> <pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -76,6 +76,12 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Status</td>
<td>
<span class="label label-{{ ipaddress.get_status_class }}">{{ ipaddress.get_status_display }}</span>
</td>
</tr>
<tr> <tr>
<td>Description</td> <td>Description</td>
<td> <td>
@ -91,8 +97,14 @@
<td> <td>
{% if ipaddress.interface %} {% if ipaddress.interface %}
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span> <span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_remove' pk=ipaddress.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
{% endif %}
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_assign' pk=ipaddress.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
{% endif %}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,56 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block title %}Assign IP Address{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Assign IP Address {{ ipaddress }} ({% if ipaddress.vrf %}VRF {{ ipaddress.vrf }}{% else %}Global Table{% endif %})</strong>
</div>
<div class="panel-body">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
<li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="search">
{% render_field form.livesearch %}
</div>
<div class="tab-pane" id="select">
{% render_field form.site %}
{% render_field form.rack %}
{% render_field form.device %}
</div>
</div>
{% render_field form.interface %}
{% render_field form.set_as_primary %}
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<button type="submit" name="_assign" class="btn btn-primary">Assign</button>
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}"></script>
{% endblock %}

View File

@ -8,6 +8,7 @@
<th>IP Address</th> <th>IP Address</th>
<th>VRF</th> <th>VRF</th>
<th>Tenant</th> <th>Tenant</th>
<th>Status</th>
<th>Assigned</th> <th>Assigned</th>
<th>Description</th> <th>Description</th>
</tr> </tr>
@ -16,6 +17,7 @@
<td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td> <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
<td>{{ ipaddress.vrf|default:"Global" }}</td> <td>{{ ipaddress.vrf|default:"Global" }}</td>
<td>{{ ipaddress.tenant }}</td> <td>{{ ipaddress.tenant }}</td>
<td>{{ ipaddress.get_status_display }}</td>
<td>{% if ipaddress.interface %}<i class="glyphicon glyphicon-ok text-success" title="{{ ipaddress.interface.device }} {{ ipaddress.interface }}"></i>{% endif %}</td> <td>{% if ipaddress.interface %}<i class="glyphicon glyphicon-ok text-success" title="{{ ipaddress.interface.device }} {{ ipaddress.interface }}"></i>{% endif %}</td>
<td>{{ ipaddress.description }}</td> <td>{{ ipaddress.description }}</td>
</tr> </tr>

View File

@ -9,6 +9,7 @@
{% render_field form.address %} {% render_field form.address %}
{% render_field form.vrf %} {% render_field form.vrf %}
{% render_field form.tenant %} {% render_field form.tenant %}
{% render_field form.status %}
{% if obj %} {% if obj %}
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label">Device</label> <label class="col-md-3 control-label">Device</label>
@ -16,8 +17,12 @@
<p class="form-control-static"> <p class="form-control-static">
{% if obj.interface %} {% if obj.interface %}
<a href="{% url 'dcim:device' pk=obj.interface.device.pk %}">{{ obj.interface.device }}</a> <a href="{% url 'dcim:device' pk=obj.interface.device.pk %}">{{ obj.interface.device }}</a>
<a href="{% url 'ipam:ipaddress_remove' pk=obj.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
{% else %} {% else %}
<span>None</span> <span class="text-muted">None</span>
{% if obj.pk %}
<a href="{% url 'ipam:ipaddress_assign' pk=obj.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
{% endif %}
{% endif %} {% endif %}
</p> </p>
</div> </div>
@ -25,7 +30,13 @@
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label">Interface</label> <label class="col-md-3 control-label">Interface</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{{ obj.interface }}</p> <p class="form-control-static">
{% if obj.interface %}
{{ obj.interface }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -43,6 +43,11 @@
<td>Name of tenant (optional)</td> <td>Name of tenant (optional)</td>
<td>ABC01</td> <td>ABC01</td>
</tr> </tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr> <tr>
<td>Device</td> <td>Device</td>
<td>Device name (optional)</td> <td>Device name (optional)</td>
@ -66,7 +71,7 @@
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>192.0.2.42/24,65000:123,ABC01,switch12,ge-0/0/31,True,Management IP</pre> <pre>192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,8 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Remove {{ ipaddress }} from {{ ipaddress.interface }}?{% endblock %}
{% block message %}
<p>Are you sure you want to remove this IP address from <strong>{{ ipaddress.interface.device }} {{ ipaddress.interface }}</strong>?</p>
{% endblock %}

View File

@ -29,7 +29,7 @@ def login(request):
# Authenticate user # Authenticate user
auth_login(request, form.get_user()) auth_login(request, form.get_user())
messages.info(request, "Logged in as {0}.".format(request.user)) messages.info(request, u"Logged in as {}.".format(request.user))
return HttpResponseRedirect(redirect_to) return HttpResponseRedirect(redirect_to)
@ -44,7 +44,7 @@ def login(request):
def logout(request): def logout(request):
auth_logout(request) auth_logout(request)
messages.info(request, "You have logged out.") messages.info(request, u"You have logged out.")
return HttpResponseRedirect(reverse('home')) return HttpResponseRedirect(reverse('home'))
@ -67,7 +67,7 @@ def change_password(request):
if form.is_valid(): if form.is_valid():
form.save() form.save()
update_session_auth_hash(request, form.user) update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.") messages.success(request, u"Your password has been changed successfully.")
return redirect('users:profile') return redirect('users:profile')
else: else:
@ -105,7 +105,7 @@ def userkey_edit(request):
uk = form.save(commit=False) uk = form.save(commit=False)
uk.user = request.user uk.user = request.user
uk.save() uk.save()
messages.success(request, "Your user key has been saved.") messages.success(request, u"Your user key has been saved.")
return redirect('users:userkey') return redirect('users:userkey')
else: else:

View File

@ -11,25 +11,51 @@ from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
EXPANSION_PATTERN = '\[(\d+-\d+)\]' NUMERIC_EXPANSION_PATTERN = '\[(\d+-\d+)\]'
IP4_EXPANSION_PATTERN = '\[([0-9]{1,3}-[0-9]{1,3})\]'
IP6_EXPANSION_PATTERN = '\[([0-9a-f]{1,4}-[0-9a-f]{1,4})\]'
def expand_pattern(string): def expand_numeric_pattern(string):
""" """
Expand a numeric pattern into a list of strings. Examples: Expand a numeric pattern into a list of strings. Examples:
'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] 'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
""" """
lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
x, y = pattern.split('-') x, y = pattern.split('-')
for i in range(int(x), int(y) + 1): for i in range(int(x), int(y) + 1):
if re.search(EXPANSION_PATTERN, remnant): if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
for string in expand_pattern(remnant): for string in expand_numeric_pattern(remnant):
yield "{}{}{}".format(lead, i, string) yield "{}{}{}".format(lead, i, string)
else: else:
yield "{}{}{}".format(lead, i, remnant) yield "{}{}{}".format(lead, i, remnant)
def expand_ipaddress_pattern(string, family):
"""
Expand an IP address pattern into a list of strings. Examples:
'192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
'2001:db8:0:[0-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:1::/64', ... '2001:db8:0:ff::/64']
"""
if family not in [4, 6]:
raise Exception("Invalid IP address family: {}".format(family))
if family == 4:
regex = IP4_EXPANSION_PATTERN
base = 10
else:
regex = IP6_EXPANSION_PATTERN
base = 16
lead, pattern, remnant = re.split(regex, string, maxsplit=1)
x, y = pattern.split('-')
for i in range(int(x, base), int(y, base) + 1):
if re.search(regex, remnant):
for string in expand_ipaddress_pattern(remnant, family):
yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
else:
yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
def add_blank_choice(choices): def add_blank_choice(choices):
""" """
Add a blank choice to the beginning of a choices list. Add a blank choice to the beginning of a choices list.
@ -178,8 +204,28 @@ class ExpandableNameField(forms.CharField):
'Example: <code>ge-0/0/[0-47]</code>' 'Example: <code>ge-0/0/[0-47]</code>'
def to_python(self, value): def to_python(self, value):
if re.search(EXPANSION_PATTERN, value): if re.search(NUMERIC_EXPANSION_PATTERN, value):
return list(expand_pattern(value)) return list(expand_numeric_pattern(value))
return [value]
class ExpandableIPAddressField(forms.CharField):
"""
A field which allows for expansion of IP address ranges
Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
"""
def __init__(self, *args, **kwargs):
super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
if not self.help_text:
self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
'Example: <code>192.0.2.[1-254]/24</code>'
def to_python(self, value):
# Hackish address family detection but it's all we have to work with
if '.' in value and re.search(IP4_EXPANSION_PATTERN, value):
return list(expand_ipaddress_pattern(value, 4))
elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value):
return list(expand_ipaddress_pattern(value, 6))
return [value] return [value]

View File

@ -67,7 +67,7 @@ class ObjectListView(View):
filename='netbox_{}'.format(model._meta.verbose_name_plural)) filename='netbox_{}'.format(model._meta.verbose_name_plural))
return response return response
except TemplateSyntaxError: except TemplateSyntaxError:
messages.error(request, "There was an error rendering the selected export template ({})." messages.error(request, u"There was an error rendering the selected export template ({})."
.format(et.name)) .format(et.name))
# Fall back to built-in CSV export # Fall back to built-in CSV export
elif 'export' in request.GET and hasattr(model, 'to_csv'): elif 'export' in request.GET and hasattr(model, 'to_csv'):
@ -129,6 +129,13 @@ class ObjectEditView(View):
else: else:
return get_object_or_404(self.model, pk=kwargs['pk']) return get_object_or_404(self.model, pk=kwargs['pk'])
def get_cancel_url(self, obj):
if hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url()
if hasattr(obj, 'get_parent_url'):
return obj.get_parent_url()
return reverse(self.cancel_url)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if kwargs: if kwargs:
@ -142,7 +149,7 @@ class ObjectEditView(View):
'obj': obj, 'obj': obj,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'form': form, 'form': form,
'cancel_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else reverse(self.cancel_url), 'cancel_url': self.get_cancel_url(obj),
}) })
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -174,14 +181,16 @@ class ObjectEditView(View):
return redirect(request.path) return redirect(request.path)
elif self.success_url: elif self.success_url:
return redirect(self.success_url) return redirect(self.success_url)
else: elif hasattr(obj, 'get_absolute_url'):
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
elif hasattr(obj, 'get_parent_url'):
return redirect(obj.get_parent_url())
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': obj, 'obj': obj,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'form': form, 'form': form,
'cancel_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else reverse(self.cancel_url), 'cancel_url': self.get_cancel_url(obj),
}) })
@ -197,6 +206,13 @@ class ObjectDeleteView(View):
else: else:
return get_object_or_404(self.model, pk=kwargs['pk']) return get_object_or_404(self.model, pk=kwargs['pk'])
def get_cancel_url(self, obj):
if hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url()
if hasattr(obj, 'get_parent_url'):
return obj.get_parent_url()
return reverse('home')
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = self.get_object(kwargs) obj = self.get_object(kwargs)
@ -206,7 +222,7 @@ class ObjectDeleteView(View):
'obj': obj, 'obj': obj,
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'cancel_url': obj.get_absolute_url(), 'cancel_url': self.get_cancel_url(obj),
}) })
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -216,19 +232,24 @@ class ObjectDeleteView(View):
if form.is_valid(): if form.is_valid():
try: try:
obj.delete() obj.delete()
msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg)
return redirect(self.redirect_url)
except ProtectedError, e: except ProtectedError, e:
handle_protectederror(obj, request, e) handle_protectederror(obj, request, e)
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg)
if self.redirect_url:
return redirect(self.redirect_url)
elif hasattr(obj, 'get_parent_url'):
return redirect(obj.get_parent_url())
else:
return redirect('home')
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': obj, 'obj': obj,
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'cancel_url': obj.get_absolute_url(), 'cancel_url': self.get_cancel_url(obj),
}) })
@ -347,7 +368,7 @@ class BulkEditView(View):
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:
messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural))
return redirect(redirect_url) return redirect(redirect_url)
return render(request, self.template_name, { return render(request, self.template_name, {
@ -460,7 +481,7 @@ class BulkDeleteView(View):
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:
messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
return redirect(redirect_url) return redirect(redirect_url)
return render(request, self.template_name, { return render(request, self.template_name, {