Parcourir la source

Merge pull request #666 from digitalocean/develop

Release v1.7.0
Jeremy Stretch il y a 9 ans
Parent
commit
57ddd5086f
61 fichiers modifiés avec 1004 ajouts et 905 suppressions
  1. 2 1
      .gitignore
  2. 1 1
      docs/data-model/extras.md
  3. 1 1
      docs/installation/upgrading.md
  4. 2 2
      netbox/dcim/api/serializers.py
  5. 20 13
      netbox/dcim/forms.py
  6. 20 0
      netbox/dcim/migrations/0020_rack_desc_units.py
  7. 31 0
      netbox/dcim/migrations/0021_add_ff_flexstack.py
  8. 104 47
      netbox/dcim/models.py
  9. 8 7
      netbox/dcim/tables.py
  10. 3 0
      netbox/dcim/tests/test_apis.py
  11. 15 15
      netbox/dcim/urls.py
  12. 172 357
      netbox/dcim/views.py
  13. 2 2
      netbox/extras/api/views.py
  14. 0 1
      netbox/extras/forms.py
  15. 29 0
      netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py
  16. 5 4
      netbox/extras/models.py
  17. 2 2
      netbox/ipam/api/serializers.py
  18. 1 1
      netbox/ipam/filters.py
  19. 44 26
      netbox/ipam/forms.py
  20. 20 0
      netbox/ipam/migrations/0009_ipaddress_add_status.py
  21. 27 0
      netbox/ipam/migrations/0010_ipaddress_help_texts.py
  22. 42 14
      netbox/ipam/models.py
  23. 5 4
      netbox/ipam/tables.py
  24. 2 0
      netbox/ipam/urls.py
  25. 72 1
      netbox/ipam/views.py
  26. 1 1
      netbox/netbox/settings.py
  27. 1 3
      netbox/netbox/urls.py
  28. 11 7
      netbox/netbox/views.py
  29. 2 2
      netbox/secrets/admin.py
  30. 2 2
      netbox/secrets/decorators.py
  31. 8 7
      netbox/secrets/forms.py
  32. 18 8
      netbox/secrets/models.py
  33. 3 3
      netbox/secrets/views.py
  34. 19 0
      netbox/templates/404.html
  35. 15 0
      netbox/templates/circuits/circuit.html
  36. 0 51
      netbox/templates/dcim/consoleport_edit.html
  37. 0 51
      netbox/templates/dcim/consoleserverport_edit.html
  38. 60 0
      netbox/templates/dcim/device_bulk_add_component.html
  39. 5 9
      netbox/templates/dcim/device_component_add.html
  40. 1 1
      netbox/templates/dcim/device_import.html
  41. 0 51
      netbox/templates/dcim/devicebay_edit.html
  42. 3 0
      netbox/templates/dcim/inc/_ipaddress.html
  43. 0 0
      netbox/templates/dcim/inc/_rack_elevation.html
  44. 1 1
      netbox/templates/dcim/inc/device_table.html
  45. 0 23
      netbox/templates/dcim/interface_add_multi.html
  46. 0 51
      netbox/templates/dcim/interface_edit.html
  47. 27 3
      netbox/templates/dcim/ipaddress_assign.html
  48. 0 51
      netbox/templates/dcim/poweroutlet_edit.html
  49. 0 51
      netbox/templates/dcim/powerport_edit.html
  50. 3 3
      netbox/templates/dcim/rack.html
  51. 1 0
      netbox/templates/dcim/rack_edit.html
  52. 6 1
      netbox/templates/dcim/rack_import.html
  53. 12 0
      netbox/templates/ipam/ipaddress.html
  54. 56 0
      netbox/templates/ipam/ipaddress_assign.html
  55. 2 0
      netbox/templates/ipam/ipaddress_bulk_edit.html
  56. 13 2
      netbox/templates/ipam/ipaddress_edit.html
  57. 6 1
      netbox/templates/ipam/ipaddress_import.html
  58. 8 0
      netbox/templates/ipam/ipaddress_unassign.html
  59. 4 4
      netbox/users/views.py
  60. 53 7
      netbox/utilities/forms.py
  61. 33 12
      netbox/utilities/views.py

+ 2 - 1
.gitignore

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

+ 1 - 1
docs/data-model/extras.md

@@ -98,4 +98,4 @@ dist-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.

+ 1 - 1
docs/installation/upgrading.md

@@ -18,7 +18,7 @@ Download and extract the latest 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:

+ 2 - 2
netbox/dcim/api/serializers.py

@@ -79,7 +79,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     class Meta:
         model = Rack
         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):
@@ -94,7 +94,7 @@ class RackDetailSerializer(RackSerializer):
 
     class Meta(RackSerializer.Meta):
         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):
         units = obj.get_rack_units(face=RACK_FACE_FRONT)

+ 20 - 13
netbox/dcim/forms.py

@@ -142,7 +142,8 @@ class RackForm(BootstrapMixin, CustomFieldForm):
 
     class Meta:
         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 = {
             'site': "The site at which the rack exists",
             'name': "Organizational rack name",
@@ -178,7 +179,8 @@ class RackFromCSVForm(forms.ModelForm):
 
     class Meta:
         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):
 
@@ -368,7 +370,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
         attrs={'filter-for': 'position'}
     ))
     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}}',
                                                        disabled_indicator='device'))
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
@@ -582,6 +584,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         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):
     model = Device
     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']
 
 
-class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
-    pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
-
-
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
@@ -1226,15 +1236,12 @@ class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
 # 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)
 
     class Meta:
         model = IPAddress
-        fields = ['address', 'vrf', 'interface', 'set_as_primary']
-        help_texts = {
-            'address': 'IPv4 or IPv6 address (with mask)'
-        }
+        fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
 
     def __init__(self, device, *args, **kwargs):
 
@@ -1251,7 +1258,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
 
 
 #
-# Interfaces
+# Modules
 #
 
 class ModuleForm(forms.ModelForm, BootstrapMixin):

+ 20 - 0
netbox/dcim/migrations/0020_rack_desc_units.py

@@ -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'),
+        ),
+    ]

+ 31 - 0
netbox/dcim/migrations/0021_add_ff_flexstack.py

@@ -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),
+        ),
+    ]

+ 104 - 47
netbox/dcim/models.py

@@ -107,6 +107,8 @@ IFACE_FF_E3 = 4050
 # Stacking
 IFACE_FF_STACKWISE = 5000
 IFACE_FF_STACKWISE_PLUS = 5050
+IFACE_FF_FLEXSTACK = 5100
+IFACE_FF_FLEXSTACK_PLUS = 5150
 # Other
 IFACE_FF_OTHER = 32767
 
@@ -164,6 +166,8 @@ IFACE_FF_CHOICES = [
         [
             [IFACE_FF_STACKWISE, 'Cisco StackWise'],
             [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')
     u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
                                                 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)
     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:
                 min_height = top_device.position + top_device.device_type.u_height - 1
                 if self.u_height < min_height:
-                    raise ValidationError("Rack must be at least {}U tall with currently installed devices."
-                                          .format(min_height))
+                    raise ValidationError({
+                        'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
+                            min_height
+                        )
+                    })
 
     def to_csv(self):
         return ','.join([
@@ -419,7 +428,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
     @property
     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
     def display_name(self):
@@ -438,7 +450,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
         """
 
         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}
 
         # Add devices to rack units list
@@ -476,7 +488,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
         """
 
         # 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
         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.
         """
-        if self.u_consumed is None:
-                self.u_consumed = 0
-        u_available = self.u_height - self.u_consumed
+        u_available = len(self.get_available_units())
         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,
                                                          exclude=[d.pk])
                 if d.position not in u_available:
-                    raise ValidationError("Device {} in rack {} does not have sufficient space to accommodate a height "
-                                          "of {}U".format(d, d.rack, self.u_height))
+                    raise ValidationError({
+                        '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():
-            raise ValidationError("Must delete all console server port templates associated with this device before "
-                                  "declassifying it as a console server.")
+            raise ValidationError({
+                '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():
-            raise ValidationError("Must delete all power outlet templates associated with this device before "
-                                  "declassifying it as a PDU.")
+            raise ValidationError({
+                '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():
-            raise ValidationError("Must delete all non-management-only interface templates associated with this device "
-                                  "before declassifying it as a network device.")
+            raise ValidationError({
+                '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():
-            raise ValidationError("Must delete all device bay templates associated with this device before "
-                                  "declassifying it as a parent device.")
+            raise ValidationError({
+                '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:
-            raise ValidationError("Child device types must be 0U.")
+            raise ValidationError({
+                'u_height': "Child device types must be 0U."
+            })
 
     @property
     def is_parent_device(self):
@@ -800,7 +822,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
     position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
                                                 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')
     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,
@@ -824,29 +846,39 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     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
         if self.position and self.face is None:
-            raise ValidationError("Must specify rack face with rack position.")
-
-        # 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("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
+            raise ValidationError({
+                'face': "Must specify rack face when defining rack position."
+            })
+
+        if self.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:
+                raise ValidationError({
+                    'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
+                            "device."
+                })
+            if self.device_type.is_child_device and self.position:
+                raise ValidationError({
+                    '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):
 
@@ -961,6 +993,9 @@ class ConsolePort(models.Model):
     def __unicode__(self):
         return self.name
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
     # Used for connections export
     def to_csv(self):
         return ','.join([
@@ -1002,6 +1037,9 @@ class ConsoleServerPort(models.Model):
     def __unicode__(self):
         return self.name
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
 
 class PowerPort(models.Model):
     """
@@ -1020,6 +1058,9 @@ class PowerPort(models.Model):
     def __unicode__(self):
         return self.name
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
     # Used for connections export
     def to_csv(self):
         return ','.join([
@@ -1055,6 +1096,9 @@ class PowerOutlet(models.Model):
     def __unicode__(self):
         return self.name
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
 
 class InterfaceManager(models.Manager):
 
@@ -1091,12 +1135,16 @@ class Interface(models.Model):
     def __unicode__(self):
         return self.name
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
-            raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
-                                                  "circuit. Disconnect the interface or choose a physical form "
-                                                  "factor."})
+            raise ValidationError({
+                'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
+                               "interface or choose a physical form factor."
+            })
 
     @property
     def is_physical(self):
@@ -1147,7 +1195,9 @@ class InterfaceConnection(models.Model):
 
     def clean(self):
         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
     def to_csv(self):
@@ -1176,12 +1226,16 @@ class DeviceBay(models.Model):
     def __unicode__(self):
         return u'{} - {}'.format(self.device.name, self.name)
 
+    def get_parent_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Validate that the parent Device can have DeviceBays
         if not self.device.device_type.is_parent_device:
-            raise ValidationError("This type of device ({}) does not support device bays."
-                                  .format(self.device.device_type))
+            raise ValidationError("This type of device ({}) does not support device bays.".format(
+                self.device.device_type
+            ))
 
         # Cannot install a device into itself, obviously
         if self.device == self.installed_device:
@@ -1208,3 +1262,6 @@ class Module(models.Model):
 
     def __unicode__(self):
         return self.name
+
+    def get_parent_url(self):
+        return reverse('dcim:device_inventory', args=[self.device.pk])

+ 8 - 7
netbox/dcim/tables.py

@@ -72,7 +72,7 @@ STATUS_ICON = """
 
 UTILIZATION_GRAPH = """
 {% 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')
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
-    u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
-    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
+    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
-                  'utilization')
+        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
+                  'get_utilization')
 
 
 class RackImportTable(BaseTable):
@@ -196,10 +195,12 @@ class DeviceTypeTable(BaseTable):
     manufacturer = tables.Column(verbose_name='Manufacturer')
     model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
     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):
         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')
     power_outlet = tables.Column(verbose_name='Outlet')
     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):
         model = PowerPort

+ 3 - 0
netbox/dcim/tests/test_apis.py

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

+ 15 - 15
netbox/dcim/urls.py

@@ -110,38 +110,38 @@ urlpatterns = [
     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+)/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+)/delete/$', views.consoleport_delete, name='consoleport_delete'),
+    url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
+    url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
 
     # 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/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+)/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+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'),
+    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.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
 
     # 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/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+)/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+)/delete/$', views.powerport_delete, name='powerport_delete'),
+    url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
+    url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
 
     # 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/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+)/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+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
+    url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
+    url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
 
     # Device bays
     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'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
-    url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
+    url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
+    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+)/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'),
 
     # 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/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+)/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'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'),
-    url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'),
+    url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
+    url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
 
     # Modules
     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+)/delete/$', views.module_delete, name='module_delete'),
+    url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
+    url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
 
 ]

+ 172 - 357
netbox/dcim/views.py

@@ -1,3 +1,4 @@
+from copy import deepcopy
 import re
 from natsort import natsorted
 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.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
-from django.db.models import Count, Sum
-from django.db.models.functions import Coalesce
+from django.db import transaction
+from django.db.models import Count
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.http import urlencode
@@ -181,8 +182,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 class RackListView(ObjectListView):
     queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
-        .annotate(device_count=Count('devices', distinct=True),
-                  u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0))
+        .annotate(device_count=Count('devices', distinct=True))
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable
@@ -275,7 +275,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class DeviceTypeListView(ObjectListView):
-    queryset = DeviceType.objects.select_related('manufacturer')
+    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
@@ -394,7 +394,7 @@ class ComponentTemplateCreateView(View):
 
             if not form.errors:
                 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:
                     return redirect(request.path)
                 else:
@@ -574,7 +574,8 @@ def device(request, pk):
     secrets = device.secrets.all()
 
     # 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
     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
 #
@@ -713,7 +788,7 @@ def consoleport_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:consoleport_add', pk=device.pk)
                 else:
@@ -722,8 +797,9 @@ def consoleport_add(request, pk):
     else:
         form = forms.ConsolePortCreateForm()
 
-    return render(request, 'dcim/consoleport_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Console Port',
         'form': form,
         '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)
         if form.is_valid():
             consoleport = form.save()
-            messages.success(request, "Connected {0} {1} to {2} {3}".format(
+            messages.success(request, u"Connected {} {} to {} {}.".format(
                 consoleport.device,
                 consoleport.name,
                 consoleport.cs_port.device,
@@ -765,7 +841,7 @@ def consoleport_disconnect(request, pk):
     consoleport = get_object_or_404(ConsolePort, pk=pk)
 
     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))
         return redirect('dcim:device', pk=consoleport.device.pk)
 
@@ -775,7 +851,7 @@ def consoleport_disconnect(request, pk):
             consoleport.cs_port = None
             consoleport.connection_status = None
             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)
 
     else:
@@ -788,49 +864,15 @@ def consoleport_disconnect(request, pk):
     })
 
 
-@permission_required('dcim.change_consoleport')
-def consoleport_edit(request, pk):
-
-    consoleport = get_object_or_404(ConsolePort, pk=pk)
-
-    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')
-def consoleport_delete(request, pk):
-
-    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)
+class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_consoleport'
+    model = ConsolePort
+    form_class = forms.ConsolePortForm
 
-    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 ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleport'
+    model = ConsolePort
 
 
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -873,7 +915,7 @@ def consoleserverport_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:consoleserverport_add', pk=device.pk)
                 else:
@@ -882,8 +924,9 @@ def consoleserverport_add(request, pk):
     else:
         form = forms.ConsoleServerPortCreateForm()
 
-    return render(request, 'dcim/consoleserverport_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Console Server Port',
         'form': form,
         'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
@@ -901,7 +944,7 @@ def consoleserverport_connect(request, pk):
             consoleport.cs_port = consoleserverport
             consoleport.connection_status = form.cleaned_data['connection_status']
             consoleport.save()
-            messages.success(request, "Connected {0} {1} to {2} {3}".format(
+            messages.success(request, u"Connected {} {} to {} {}.".format(
                 consoleport.device,
                 consoleport.name,
                 consoleserverport.device,
@@ -925,7 +968,7 @@ def consoleserverport_disconnect(request, pk):
     consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
 
     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))
         return redirect('dcim:device', pk=consoleserverport.device.pk)
 
@@ -936,7 +979,7 @@ def consoleserverport_disconnect(request, pk):
             consoleport.cs_port = None
             consoleport.connection_status = None
             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)
 
     else:
@@ -949,49 +992,15 @@ def consoleserverport_disconnect(request, pk):
     })
 
 
-@permission_required('dcim.change_consoleserverport')
-def consoleserverport_edit(request, pk):
-
-    consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
+class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_consoleserverport'
+    model = ConsoleServerPort
+    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')
-def consoleserverport_delete(request, pk):
-
-    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 ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleserverport'
+    model = ConsoleServerPort
 
 
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1026,7 +1035,7 @@ def powerport_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:powerport_add', pk=device.pk)
                 else:
@@ -1035,8 +1044,9 @@ def powerport_add(request, pk):
     else:
         form = forms.PowerPortCreateForm()
 
-    return render(request, 'dcim/powerport_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Power Port',
         'form': form,
         '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)
         if form.is_valid():
             powerport = form.save()
-            messages.success(request, "Connected {0} {1} to {2} {3}".format(
+            messages.success(request, u"Connected {} {} to {} {}.".format(
                 powerport.device,
                 powerport.name,
                 powerport.power_outlet.device,
@@ -1078,7 +1088,7 @@ def powerport_disconnect(request, pk):
     powerport = get_object_or_404(PowerPort, pk=pk)
 
     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))
         return redirect('dcim:device', pk=powerport.device.pk)
 
@@ -1088,7 +1098,7 @@ def powerport_disconnect(request, pk):
             powerport.power_outlet = None
             powerport.connection_status = None
             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)
 
     else:
@@ -1101,48 +1111,15 @@ def powerport_disconnect(request, pk):
     })
 
 
-@permission_required('dcim.change_powerport')
-def powerport_edit(request, pk):
-
-    powerport = get_object_or_404(PowerPort, pk=pk)
-
-    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')
-def powerport_delete(request, pk):
-
-    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)
+class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_powerport'
+    model = PowerPort
+    form_class = forms.PowerPortForm
 
-    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 PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_powerport'
+    model = PowerPort
 
 
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1184,7 +1161,7 @@ def poweroutlet_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:poweroutlet_add', pk=device.pk)
                 else:
@@ -1193,8 +1170,9 @@ def poweroutlet_add(request, pk):
     else:
         form = forms.PowerOutletCreateForm()
 
-    return render(request, 'dcim/poweroutlet_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Power Outlet',
         'form': form,
         'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
@@ -1212,7 +1190,7 @@ def poweroutlet_connect(request, pk):
             powerport.power_outlet = poweroutlet
             powerport.connection_status = form.cleaned_data['connection_status']
             powerport.save()
-            messages.success(request, "Connected {0} {1} to {2} {3}".format(
+            messages.success(request, u"Connected {} {} to {} {}.".format(
                 powerport.device,
                 powerport.name,
                 poweroutlet.device,
@@ -1236,7 +1214,7 @@ def poweroutlet_disconnect(request, pk):
     poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
 
     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)
 
     if request.method == 'POST':
@@ -1246,7 +1224,7 @@ def poweroutlet_disconnect(request, pk):
             powerport.power_outlet = None
             powerport.connection_status = None
             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)
 
     else:
@@ -1259,49 +1237,15 @@ def poweroutlet_disconnect(request, pk):
     })
 
 
-@permission_required('dcim.change_poweroutlet')
-def poweroutlet_edit(request, pk):
+class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_poweroutlet'
+    model = PowerOutlet
+    form_class = forms.PowerOutletForm
 
-    poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
-
-    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')
-def poweroutlet_delete(request, pk):
-
-    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 PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_poweroutlet'
+    model = PowerOutlet
 
 
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1340,97 +1284,32 @@ def interface_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:interface_add', pk=device.pk)
                 else:
                     return redirect('dcim:device', pk=device.pk)
 
     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,
+        'component_type': 'Interface',
         'form': form,
         'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
 
 
-@permission_required('dcim.change_interface')
-def interface_edit(request, pk):
-
-    interface = get_object_or_404(Interface, pk=pk)
-
-    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')
-def interface_delete(request, pk):
-
-    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 = []
+class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_interface'
+    model = Interface
+    form_class = forms.InterfaceForm
 
-        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 InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_interface'
+    model = Interface
 
 
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -1474,7 +1353,7 @@ def devicebay_add(request, pk):
 
             if not form.errors:
                 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:
                     return redirect('dcim:devicebay_add', pk=device.pk)
                 else:
@@ -1483,55 +1362,23 @@ def devicebay_add(request, pk):
     else:
         form = forms.DeviceBayCreateForm()
 
-    return render(request, 'dcim/devicebay_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Device Bay',
         'form': form,
         'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
 
 
-@permission_required('dcim.change_devicebay')
-def devicebay_edit(request, pk):
-
-    devicebay = get_object_or_404(DeviceBay, pk=pk)
-
-    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)
+class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_devicebay'
+    model = DeviceBay
+    form_class = forms.DeviceBayForm
 
-    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')
-def devicebay_delete(request, pk):
 
-    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}),
-    })
+class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_devicebay'
+    model = DeviceBay
 
 
 @permission_required('dcim.change_devicebay')
@@ -1547,7 +1394,7 @@ def devicebay_populate(request, pk):
             device_bay.save()
 
             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)
 
     else:
@@ -1571,7 +1418,7 @@ def devicebay_depopulate(request, pk):
             removed_device = device_bay.installed_device
             device_bay.installed_device = None
             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)
 
     else:
@@ -1603,7 +1450,7 @@ def interfaceconnection_add(request, pk):
         form = forms.InterfaceConnectionForm(device, request.POST)
         if form.is_valid():
             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,
                 interfaceconnection.interface_b.device,
@@ -1643,7 +1490,7 @@ def interfaceconnection_delete(request, pk):
         form = forms.InterfaceConnectionDeletionForm(request.POST)
         if form.is_valid():
             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,
                 interfaceconnection.interface_b.device,
@@ -1715,7 +1562,7 @@ class InterfaceConnectionsListView(ObjectListView):
 # IP addresses
 #
 
-@permission_required('ipam.add_ipaddress')
+@permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
 def ipaddress_assign(request, pk):
 
     device = get_object_or_404(Device, pk=pk)
@@ -1727,8 +1574,8 @@ def ipaddress_assign(request, pk):
             ipaddress = form.save(commit=False)
             ipaddress.interface = form.cleaned_data['interface']
             ipaddress.save()
-            messages.success(request, "Added new IP address {0} to interface {1}".format(ipaddress,
-                                                                                         ipaddress.interface))
+            form.save_custom_fields()
+            messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
 
             if form.cleaned_data['set_as_primary']:
                 if ipaddress.family == 4:
@@ -1767,7 +1614,7 @@ def module_add(request, pk):
             module = form.save(commit=False)
             module.device = device
             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:
                 return redirect('dcim:module_add', pk=module.device.pk)
             else:
@@ -1776,52 +1623,20 @@ def module_add(request, pk):
     else:
         form = forms.ModuleForm()
 
-    return render(request, 'dcim/module_edit.html', {
+    return render(request, 'dcim/device_component_add.html', {
         'device': device,
+        'component_type': 'Module',
         'form': form,
         'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}),
     })
 
 
-@permission_required('dcim.change_module')
-def module_edit(request, pk):
-
-    module = get_object_or_404(Module, pk=pk)
-
-    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')
-def module_delete(request, pk):
-
-    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)
+class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_module'
+    model = Module
+    form_class = forms.ModuleForm
 
-    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}),
-    })
+class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_module'
+    model = Module

+ 2 - 2
netbox/extras/api/views.py

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

+ 0 - 1
netbox/extras/forms.py

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

+ 29 - 0
netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py

@@ -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),
+    ]

+ 5 - 4
netbox/extras/models.py

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

+ 2 - 2
netbox/ipam/api/serializers.py

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

+ 1 - 1
netbox/ipam/filters.py

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

+ 44 - 26
netbox/ipam/forms.py

@@ -1,20 +1,19 @@
 from django import forms
 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 tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
+    APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice,
 )
 
 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 = [
     ('', 'All'),
     (4, 'IPv4'),
@@ -173,16 +172,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
         else:
             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):
     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)
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
     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)
     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(
         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:
         model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
-        help_texts = {
-            'address': "IPv4 or IPv6 address and mask",
-            'vrf': "VRF (if applicable)",
+        fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
+        widgets = {
+            'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
         }
 
     def __init__(self, *args, **kwargs):
@@ -347,11 +332,35 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 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):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
                                  error_messages={'invalid_choice': 'VRF not found.'})
     tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
                                     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',
                                     error_messages={'invalid_choice': 'Device not found.'})
     interface_name = forms.CharField(required=False)
@@ -359,7 +368,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
 
     class Meta:
         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):
 
@@ -406,12 +415,20 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
     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)
 
     class Meta:
         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):
     model = IPAddress
     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'))
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
                                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):
     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',
                                    error_messages={'invalid_choice': 'VLAN group not found.'})
     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)
     group = forms.ModelChoiceField(queryset=VLANGroup.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)
     description = forms.CharField(max_length=100, required=False)
 

+ 20 - 0
netbox/ipam/migrations/0009_ipaddress_add_status.py

@@ -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'),
+        ),
+    ]

+ 27 - 0
netbox/ipam/migrations/0010_ipaddress_help_texts.py

@@ -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)'),
+        ),
+    ]

+ 42 - 14
netbox/ipam/models.py

@@ -29,6 +29,12 @@ PREFIX_STATUS_CHOICES = (
     (3, 'Deprecated')
 )
 
+IPADDRESS_STATUS_CHOICES = (
+    (1, 'Active'),
+    (2, 'Reserved'),
+    (5, 'DHCP')
+)
+
 VLAN_STATUS_CHOICES = (
     (1, 'Active'),
     (2, 'Reserved'),
@@ -40,6 +46,8 @@ STATUS_CHOICE_CLASSES = {
     1: 'primary',
     2: 'info',
     3: 'danger',
+    4: 'warning',
+    5: 'success',
 }
 
 
@@ -131,16 +139,22 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
             if self.pk:
                 covering_aggregates = covering_aggregates.exclude(pk=self.pk)
             if covering_aggregates:
-                raise ValidationError("{} is already covered by an existing aggregate ({})"
-                                      .format(self.prefix, covering_aggregates[0]))
+                raise ValidationError({
+                    '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
             covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
             if self.pk:
                 covered_aggregates = covered_aggregates.exclude(pk=self.pk)
             if covered_aggregates:
-                raise ValidationError("{} overlaps with an existing aggregate ({})"
-                                      .format(self.prefix, covered_aggregates[0]))
+                raise ValidationError({
+                    'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format(
+                        self.prefix, covered_aggregates[0]
+                    )
+                })
 
     def save(self, *args, **kwargs):
         if self.prefix:
@@ -260,14 +274,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
         return reverse('ipam:prefix', args=[self.pk])
 
     def clean(self):
+
         # Disallow host masks
         if self.prefix:
             if self.prefix.version == 4 and self.prefix.prefixlen == 32:
-                raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
-                                      "instead.")
+                raise ValidationError({
+                    'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
+                })
             elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
-                raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
-                                      "instead.")
+                raise ValidationError({
+                    'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
+                })
 
     def save(self, *args, **kwargs):
         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.
     """
     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,
                             verbose_name='VRF')
     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,
                                   null=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)
     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))\
                 .exclude(pk=self.pk)
             if duplicate_ips:
-                raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
-                                                                                        duplicate_ips.first()))
+                raise ValidationError({
+                    'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first())
+                })
         elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
             duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
                 .exclude(pk=self.pk)
             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):
         if self.address:
@@ -387,6 +409,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             str(self.address),
             self.vrf.rd if self.vrf else '',
             self.tenant.name if self.tenant else '',
+            self.get_status_display(),
             self.device.identifier if self.device else '',
             self.interface.name if self.interface else '',
             'True' if is_primary else '',
@@ -399,6 +422,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             return self.interface.device
         return None
 
+    def get_status_class(self):
+        return STATUS_CHOICE_CLASSES[self.status]
+
 
 class VLANGroup(models.Model):
     """
@@ -465,7 +491,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 
         # Validate VLAN group
         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):
         return ','.join([

+ 5 - 4
netbox/ipam/tables.py

@@ -14,7 +14,7 @@ RIR_ACTIONS = """
 
 UTILIZATION_GRAPH = """
 {% load helpers %}
-{% utilization_graph record.get_utilization %}
+{% utilization_graph value %}
 """
 
 ROLE_ACTIONS = """
@@ -125,13 +125,13 @@ class AggregateTable(BaseTable):
     prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
     rir = tables.Column(verbose_name='RIR')
     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')
     description = tables.Column(orderable=False, verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         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):
     pk = ToggleColumn()
     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')
     tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
     device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
@@ -202,7 +203,7 @@ class IPAddressTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = IPAddress
-        fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
+        fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
         }

+ 2 - 0
netbox/ipam/urls.py

@@ -56,6 +56,8 @@ urlpatterns = [
     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+)/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'),
 
     # VLAN groups

+ 72 - 1
netbox/ipam/views.py

@@ -1,11 +1,15 @@
 import netaddr
 from django_tables2 import RequestConfig
 
+from django.contrib.auth.decorators import permission_required
 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.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, redirect, render
 
 from dcim.models import Device
+from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     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):
     permission_required = 'ipam.change_ipaddress'
     model = IPAddress

+ 1 - 1
netbox/netbox/settings.py

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

+ 1 - 3
netbox/netbox/urls.py

@@ -1,9 +1,8 @@
 from django.conf import settings
 from django.conf.urls import include, url
 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
 
 
@@ -36,7 +35,6 @@ _patterns = [
     url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 
     # Error testing
-    url(r'^404/$', page_not_found),
     url(r'^500/$', trigger_500),
 
     # Admin

+ 11 - 7
netbox/netbox/views.py

@@ -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):
-    """Custom server error handler"""
+    """
+    Custom server error handler
+    """
     type_, error, traceback = sys.exc_info()
     return render(request, '500.html', {
         'exception': str(type_),
         'error': error,
     }, 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.")

+ 2 - 2
netbox/secrets/admin.py

@@ -34,7 +34,7 @@ class UserKeyAdmin(admin.ModelAdmin):
         try:
             my_userkey = UserKey.objects.get(user=request.user)
         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/')
 
         if 'activate' in request.POST:
@@ -46,7 +46,7 @@ class UserKeyAdmin(admin.ModelAdmin):
                         uk.activate(master_key)
                     return redirect('/admin/secrets/userkey/')
                 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:
             form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
 

+ 2 - 2
netbox/secrets/decorators.py

@@ -14,10 +14,10 @@ def userkey_required():
             try:
                 uk = UserKey.objects.get(user=request.user)
             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')
             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 view(request, *args, **kwargs)
         return wrapped_view

+ 8 - 7
netbox/secrets/forms.py

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

+ 18 - 8
netbox/secrets/models.py

@@ -81,24 +81,34 @@ class UserKey(CreatedUpdatedModel):
 
     def clean(self, *args, **kwargs):
 
-        # Validate the public key format and length.
         if self.public_key:
+
+            # Validate the public key format
             try:
                 pubkey = RSA.importKey(self.public_key)
             except ValueError:
-                raise ValidationError("Invalid RSA key format.")
+                raise ValidationError({
+                    'public_key': "Invalid RSA key format."
+                })
             except:
                 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).")
-            # 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:
-                raise ValidationError("Insufficient key length. Keys must be at least {} bits long."
-                                      .format(settings.SECRETS_MIN_PUBKEY_SIZE))
+                raise ValidationError({
+                    '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
             if pubkey_length > 4096:
-                raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits."
-                                      .format(pubkey_length))
+                raise ValidationError({
+                    'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
+                        pubkey_length
+                    )
+                })
 
         super(UserKey, self).clean()
 

+ 3 - 3
netbox/secrets/views.py

@@ -90,7 +90,7 @@ def secret_add(request, pk):
                 secret.encrypt(master_key)
                 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:
                     return redirect('dcim:device_addsecret', pk=device.pk)
                 else:
@@ -135,7 +135,7 @@ def secret_edit(request, pk):
             else:
                 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)
 
     else:
@@ -180,7 +180,7 @@ def secret_import(request):
                             new_secrets.append(secret)
 
                     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', {
                         'table': table,

+ 19 - 0
netbox/templates/404.html

@@ -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 %}

+ 15 - 0
netbox/templates/circuits/circuit.html

@@ -131,6 +131,21 @@
                         {% endif %}
                     </td>
                 </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>
                     <td>Cross-Connect</td>
                     <td>

+ 0 - 51
netbox/templates/dcim/consoleport_edit.html

@@ -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 %}

+ 0 - 51
netbox/templates/dcim/consoleserverport_edit.html

@@ -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 %}

+ 60 - 0
netbox/templates/dcim/device_bulk_add_component.html

@@ -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 %}

+ 5 - 9
netbox/templates/dcim/module_edit.html → netbox/templates/dcim/device_component_add.html

@@ -1,7 +1,7 @@
 {% extends '_base.html' %}
 {% 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 %}
 <form action="." method="post" class="form form-horizontal">
@@ -18,13 +18,13 @@
             {% endif %}
             <div class="panel panel-default">
                 <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 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 module %}{{ module.device }}{% else %}{{ device }}{% endif %}</p>
+                            <p class="form-control-static">{{ device }}</p>
                         </div>
                     </div>
                     {% render_form form %}
@@ -32,12 +32,8 @@
             </div>
 		    <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
-                    {% if module.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 %}
+                    <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>
                     <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>

+ 1 - 1
netbox/templates/dcim/device_import.html

@@ -78,7 +78,7 @@
 				</tr>
 				<tr>
 					<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>
 				</tr>
 				<tr>

+ 0 - 51
netbox/templates/dcim/devicebay_edit.html

@@ -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 %}

+ 3 - 0
netbox/templates/dcim/inc/_ipaddress.html

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

+ 0 - 0
netbox/templates/dcim/_rack_elevation.html → netbox/templates/dcim/inc/_rack_elevation.html


+ 1 - 1
netbox/templates/dcim/inc/device_table.html

@@ -2,7 +2,7 @@
 
 {% block extra_actions %}
     {% 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
         </button>
     {% endif %}

+ 0 - 23
netbox/templates/dcim/interface_add_multi.html

@@ -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 %}

+ 0 - 51
netbox/templates/dcim/interface_edit.html

@@ -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 %}

+ 27 - 3
netbox/templates/dcim/ipaddress_assign.html

@@ -1,7 +1,7 @@
 {% extends '_base.html' %}
 {% load form_helpers %}
 
-{% block title %}Add an IP Address{% endblock %}
+{% block title %}Assign an IP Address{% endblock %}
 
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
@@ -18,10 +18,34 @@
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">
-                    Add an IP Address
+                    <strong>IP Address</strong>
                 </div>
                 <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 class="form-group">

+ 0 - 51
netbox/templates/dcim/poweroutlet_edit.html

@@ -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 %}

+ 0 - 51
netbox/templates/dcim/powerport_edit.html

@@ -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 %}

+ 3 - 3
netbox/templates/dcim/rack.html

@@ -122,7 +122,7 @@
                 </tr>
                 <tr>
                     <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>
                     <td>Devices</td>
@@ -189,13 +189,13 @@
           <div class="rack_header">
             <h4>Front</h4>
           </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 class="col-md-6 col-sm-6 col-xs-12">
         <div class="rack_header">
             <h4>Rear</h4>
         </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>

+ 1 - 0
netbox/templates/dcim/rack_edit.html

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

+ 6 - 1
netbox/templates/dcim/rack_import.html

@@ -73,10 +73,15 @@
 					<td>Height in rack units</td>
 					<td>42</td>
 				</tr>
+				<tr>
+					<td>Descending units</td>
+					<td>Units are numbered top-to-bottom</td>
+					<td>False</td>
+				</tr>
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

+ 12 - 0
netbox/templates/ipam/ipaddress.html

@@ -76,6 +76,12 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Status</td>
+                    <td>
+                        <span class="label label-{{ ipaddress.get_status_class }}">{{ ipaddress.get_status_display }}</span>
+                    </td>
+                </tr>
                 <tr>
                     <td>Description</td>
                     <td>
@@ -91,8 +97,14 @@
                     <td>
                         {% if ipaddress.interface %}
                             <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 %}
                             <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 %}
                     </td>
                 </tr>

+ 56 - 0
netbox/templates/ipam/ipaddress_assign.html

@@ -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 %}

+ 2 - 0
netbox/templates/ipam/ipaddress_bulk_edit.html

@@ -8,6 +8,7 @@
         <th>IP Address</th>
         <th>VRF</th>
         <th>Tenant</th>
+        <th>Status</th>
         <th>Assigned</th>
         <th>Description</th>
     </tr>
@@ -16,6 +17,7 @@
             <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
             <td>{{ ipaddress.vrf|default:"Global" }}</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>{{ ipaddress.description }}</td>
         </tr>

+ 13 - 2
netbox/templates/ipam/ipaddress_edit.html

@@ -9,6 +9,7 @@
             {% render_field form.address %}
             {% render_field form.vrf %}
             {% render_field form.tenant %}
+            {% render_field form.status %}
             {% if obj %}
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>
@@ -16,8 +17,12 @@
                         <p class="form-control-static">
                             {% if obj.interface %}
                                 <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 %}
-                                <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 %}
                         </p>
                     </div>
@@ -25,7 +30,13 @@
                 <div class="form-group">
                     <label class="col-md-3 control-label">Interface</label>
                     <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>
             {% endif %}

+ 6 - 1
netbox/templates/ipam/ipaddress_import.html

@@ -43,6 +43,11 @@
 					<td>Name of tenant (optional)</td>
 					<td>ABC01</td>
 				</tr>
+				<tr>
+					<td>Status</td>
+					<td>Current status</td>
+					<td>Active</td>
+				</tr>
 				<tr>
 					<td>Device</td>
 					<td>Device name (optional)</td>
@@ -66,7 +71,7 @@
 			</tbody>
 		</table>
 		<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>
 {% endblock %}

+ 8 - 0
netbox/templates/ipam/ipaddress_unassign.html

@@ -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 %}

+ 4 - 4
netbox/users/views.py

@@ -29,7 +29,7 @@ def login(request):
 
             # Authenticate 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)
 
@@ -44,7 +44,7 @@ def login(request):
 def logout(request):
 
     auth_logout(request)
-    messages.info(request, "You have logged out.")
+    messages.info(request, u"You have logged out.")
     return HttpResponseRedirect(reverse('home'))
 
 
@@ -67,7 +67,7 @@ def change_password(request):
         if form.is_valid():
             form.save()
             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')
 
     else:
@@ -105,7 +105,7 @@ def userkey_edit(request):
             uk = form.save(commit=False)
             uk.user = request.user
             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')
 
     else:

+ 53 - 7
netbox/utilities/forms.py

@@ -11,25 +11,51 @@ from django.utils.html import format_html
 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:
       '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']
     """
-    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('-')
     for i in range(int(x), int(y) + 1):
-        if re.search(EXPANSION_PATTERN, remnant):
-            for string in expand_pattern(remnant):
+        if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
+            for string in expand_numeric_pattern(remnant):
                 yield "{}{}{}".format(lead, i, string)
         else:
             yield "{}{}{}".format(lead, i, remnant)
 
 
+def expand_ipaddress_pattern(string, family):
+    """
+    Expand an IP address pattern into a list of strings. Examples:
+      '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):
     """
     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>'
 
     def to_python(self, value):
-        if re.search(EXPANSION_PATTERN, value):
-            return list(expand_pattern(value))
+        if re.search(NUMERIC_EXPANSION_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]
 
 

+ 33 - 12
netbox/utilities/views.py

@@ -67,7 +67,7 @@ class ObjectListView(View):
                                           filename='netbox_{}'.format(model._meta.verbose_name_plural))
                 return response
             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))
         # Fall back to built-in CSV export
         elif 'export' in request.GET and hasattr(model, 'to_csv'):
@@ -129,6 +129,13 @@ class ObjectEditView(View):
         else:
             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):
 
         if kwargs:
@@ -142,7 +149,7 @@ class ObjectEditView(View):
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             '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):
@@ -174,14 +181,16 @@ class ObjectEditView(View):
                 return redirect(request.path)
             elif self.success_url:
                 return redirect(self.success_url)
-            else:
+            elif hasattr(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, {
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             '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:
             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):
 
         obj = self.get_object(kwargs)
@@ -206,7 +222,7 @@ class ObjectDeleteView(View):
             'obj': obj,
             'form': form,
             '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):
@@ -216,19 +232,24 @@ class ObjectDeleteView(View):
         if form.is_valid():
             try:
                 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:
                 handle_protectederror(obj, request, e)
                 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, {
             'obj': obj,
             'form': form,
             '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)
         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 render(request, self.template_name, {
@@ -460,7 +481,7 @@ class BulkDeleteView(View):
 
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         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 render(request, self.template_name, {