Bläddra i källkod

Merge pull request #849 from digitalocean/develop

Release v1.8.3
Jeremy Stretch 9 år sedan
förälder
incheckning
c90cecc2fb
99 ändrade filer med 662 tillägg och 459 borttagningar
  1. 2 0
      .gitignore
  2. 3 0
      .travis.yml
  3. 17 0
      docs/installation/netbox.md
  4. 1 0
      netbox/circuits/__init__.py
  5. 9 0
      netbox/circuits/apps.py
  6. 2 0
      netbox/circuits/forms.py
  7. 9 4
      netbox/circuits/models.py
  8. 13 0
      netbox/circuits/signals.py
  9. 14 13
      netbox/circuits/views.py
  10. 2 1
      netbox/dcim/api/serializers.py
  11. 4 3
      netbox/dcim/api/views.py
  12. 44 13
      netbox/dcim/forms.py
  13. 46 23
      netbox/dcim/models.py
  14. 38 37
      netbox/dcim/tests/test_apis.py
  15. 75 66
      netbox/dcim/views.py
  16. 1 1
      netbox/extras/api/renderers.py
  17. 15 7
      netbox/extras/models.py
  18. 2 2
      netbox/generate_secret_key.py
  19. 8 3
      netbox/ipam/forms.py
  20. 20 0
      netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py
  21. 21 9
      netbox/ipam/models.py
  22. 2 1
      netbox/ipam/tables.py
  23. 43 35
      netbox/ipam/views.py
  24. 3 3
      netbox/netbox/settings.py
  25. 1 1
      netbox/netbox/urls.py
  26. 8 0
      netbox/project-static/js/forms.js
  27. 2 2
      netbox/project-static/js/secrets.js
  28. 1 0
      netbox/secrets/forms.py
  29. 7 4
      netbox/secrets/models.py
  30. 0 1
      netbox/secrets/tests/__init__.py
  31. 8 8
      netbox/secrets/views.py
  32. 3 0
      netbox/templates/_base.html
  33. 1 1
      netbox/templates/circuits/circuit_import.html
  34. 0 1
      netbox/templates/circuits/circuit_list.html
  35. 1 1
      netbox/templates/circuits/circuittermination_edit.html
  36. 1 1
      netbox/templates/circuits/provider_import.html
  37. 0 1
      netbox/templates/circuits/provider_list.html
  38. 1 1
      netbox/templates/dcim/console_connections_list.html
  39. 1 1
      netbox/templates/dcim/consoleport_connect.html
  40. 1 1
      netbox/templates/dcim/consoleserverport_connect.html
  41. 4 4
      netbox/templates/dcim/device.html
  42. 3 3
      netbox/templates/dcim/device_bulk_add_component.html
  43. 1 1
      netbox/templates/dcim/device_component_add.html
  44. 1 1
      netbox/templates/dcim/device_import.html
  45. 1 1
      netbox/templates/dcim/device_import_child.html
  46. 25 1
      netbox/templates/dcim/device_list.html
  47. 1 1
      netbox/templates/dcim/devicebay_populate.html
  48. 1 1
      netbox/templates/dcim/devicetype_component_add.html
  49. 0 1
      netbox/templates/dcim/devicetype_list.html
  50. 1 1
      netbox/templates/dcim/inc/consoleport.html
  51. 1 1
      netbox/templates/dcim/inc/consoleserverport.html
  52. 6 6
      netbox/templates/dcim/inc/device_table.html
  53. 1 1
      netbox/templates/dcim/inc/devicebay.html
  54. 1 1
      netbox/templates/dcim/inc/interface.html
  55. 1 1
      netbox/templates/dcim/inc/poweroutlet.html
  56. 1 1
      netbox/templates/dcim/inc/powerport.html
  57. 1 1
      netbox/templates/dcim/interface_connections_list.html
  58. 1 1
      netbox/templates/dcim/interfaceconnection_edit.html
  59. 1 1
      netbox/templates/dcim/ipaddress_assign.html
  60. 1 1
      netbox/templates/dcim/power_connections_list.html
  61. 1 1
      netbox/templates/dcim/poweroutlet_connect.html
  62. 1 1
      netbox/templates/dcim/powerport_connect.html
  63. 1 1
      netbox/templates/dcim/rack_import.html
  64. 0 1
      netbox/templates/dcim/rack_list.html
  65. 1 1
      netbox/templates/dcim/rackgroup_list.html
  66. 1 1
      netbox/templates/dcim/site_import.html
  67. 0 1
      netbox/templates/dcim/site_list.html
  68. 0 32
      netbox/templates/inc/filter_panel.html
  69. 28 7
      netbox/templates/inc/search_panel.html
  70. 1 1
      netbox/templates/ipam/aggregate_import.html
  71. 0 1
      netbox/templates/ipam/aggregate_list.html
  72. 1 1
      netbox/templates/ipam/ipaddress_assign.html
  73. 1 1
      netbox/templates/ipam/ipaddress_import.html
  74. 0 2
      netbox/templates/ipam/ipaddress_list.html
  75. 1 1
      netbox/templates/ipam/prefix_import.html
  76. 0 1
      netbox/templates/ipam/prefix_list.html
  77. 1 1
      netbox/templates/ipam/rir_list.html
  78. 1 1
      netbox/templates/ipam/vlan_import.html
  79. 0 1
      netbox/templates/ipam/vlan_list.html
  80. 1 1
      netbox/templates/ipam/vlangroup_list.html
  81. 1 1
      netbox/templates/ipam/vrf_import.html
  82. 0 1
      netbox/templates/ipam/vrf_list.html
  83. 1 1
      netbox/templates/secrets/secret_edit.html
  84. 1 1
      netbox/templates/secrets/secret_import.html
  85. 0 1
      netbox/templates/secrets/secret_list.html
  86. 1 1
      netbox/templates/tenancy/tenant_import.html
  87. 0 1
      netbox/templates/tenancy/tenant_list.html
  88. 3 3
      netbox/templates/utilities/bulk_edit_form.html
  89. 1 1
      netbox/templates/utilities/confirm_bulk_delete.html
  90. 1 1
      netbox/templates/utilities/confirmation_form.html
  91. 1 1
      netbox/templates/utilities/obj_edit.html
  92. 26 13
      netbox/templates/utilities/obj_table.html
  93. 1 0
      netbox/tenancy/forms.py
  94. 5 2
      netbox/tenancy/models.py
  95. 8 8
      netbox/tenancy/views.py
  96. 31 13
      netbox/utilities/forms.py
  97. 0 4
      netbox/utilities/tables.py
  98. 0 18
      netbox/utilities/templatetags/helpers.py
  99. 58 55
      netbox/utilities/views.py

+ 2 - 0
.gitignore

@@ -1,8 +1,10 @@
 *.pyc
 /netbox/netbox/configuration.py
+/netbox/netbox/ldap_config.py
 /netbox/static
 .idea
 /*.sh
 !upgrade.sh
 fabfile.py
 *.swp
+gunicorn_config.py

+ 3 - 0
.travis.yml

@@ -9,6 +9,9 @@ env:
 language: python
 python:
   - "2.7"
+  - "3.4"
+  - "3.5"
+  - "3.6"
 install:
   - pip install -r requirements.txt
   - pip install pep8

+ 17 - 0
docs/installation/netbox.md

@@ -2,12 +2,29 @@
 
 **Debian/Ubuntu**
 
+Python 3:
+
+```no-highlight
+# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
+```
+
+Python 2:
+
 ```no-highlight
 # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
 ```
 
 **CentOS/RHEL**
 
+Python 3:
+
+```no-highlight
+# yum install -y epel-release
+# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
+```
+
+Python 2:
+
 ```no-highlight
 # yum install -y epel-release
 # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel

+ 1 - 0
netbox/circuits/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'circuits.apps.CircuitsConfig'

+ 9 - 0
netbox/circuits/apps.py

@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class CircuitsConfig(AppConfig):
+    name = "circuits"
+    verbose_name = "Circuits"
+
+    def ready(self):
+        import circuits.signals

+ 2 - 0
netbox/circuits/forms.py

@@ -62,6 +62,7 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Provider
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
 
 
@@ -126,6 +127,7 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
+    q = forms.CharField(required=False, label='Search')
     type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
                              to_field_name='slug')
     provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),

+ 9 - 4
netbox/circuits/models.py

@@ -1,6 +1,7 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.urlresolvers import reverse
 from django.db import models
+from django.utils.encoding import python_2_unicode_compatible
 
 from dcim.fields import ASNField
 from extras.models import CustomFieldModel, CustomFieldValue
@@ -33,6 +34,7 @@ def humanize_speed(speed):
         return '{} Kbps'.format(speed)
 
 
+@python_2_unicode_compatible
 class Provider(CreatedUpdatedModel, CustomFieldModel):
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -51,7 +53,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -67,6 +69,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
         ])
 
 
+@python_2_unicode_compatible
 class CircuitType(models.Model):
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@@ -78,13 +81,14 @@ class CircuitType(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
 
 
+@python_2_unicode_compatible
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
@@ -105,7 +109,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
         ordering = ['provider', 'cid']
         unique_together = ['provider', 'cid']
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} {}'.format(self.provider, self.cid)
 
     def get_absolute_url(self):
@@ -141,6 +145,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     commit_rate_human.admin_order_field = 'commit_rate'
 
 
+@python_2_unicode_compatible
 class CircuitTermination(models.Model):
     circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
     term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
@@ -156,7 +161,7 @@ class CircuitTermination(models.Model):
         ordering = ['circuit', 'term_side']
         unique_together = ['circuit', 'term_side']
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
 
     def get_peer_termination(self):

+ 13 - 0
netbox/circuits/signals.py

@@ -0,0 +1,13 @@
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+from django.utils import timezone
+
+from .models import Circuit, CircuitTermination
+
+
+@receiver((post_save, post_delete), sender=CircuitTermination)
+def update_circuit(instance, **kwargs):
+    """
+    When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
+    """
+    Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now())

+ 14 - 13
netbox/circuits/views.py

@@ -25,7 +25,6 @@ class ProviderListView(ObjectListView):
     filter = filters.ProviderFilter
     filter_form = forms.ProviderFilterForm
     table = tables.ProviderTable
-    edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
     template_name = 'circuits/provider_list.html'
 
 
@@ -47,7 +46,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
     model = Provider
     form_class = forms.ProviderForm
     template_name = 'circuits/provider_edit.html'
-    obj_list_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -61,21 +60,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.ProviderImportForm
     table = tables.ProviderTable
     template_name = 'circuits/provider_import.html'
-    obj_list_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_provider'
     cls = Provider
+    filter = filters.ProviderFilter
     form = forms.ProviderBulkEditForm
     template_name = 'circuits/provider_bulk_edit.html'
-    default_redirect_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_provider'
     cls = Provider
-    default_redirect_url = 'circuits:provider_list'
+    filter = filters.ProviderFilter
+    default_return_url = 'circuits:provider_list'
 
 
 #
@@ -85,7 +86,6 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitTypeListView(ObjectListView):
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     table = tables.CircuitTypeTable
-    edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
     template_name = 'circuits/circuittype_list.html'
 
 
@@ -101,7 +101,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     cls = CircuitType
-    default_redirect_url = 'circuits:circuittype_list'
+    default_return_url = 'circuits:circuittype_list'
 
 
 #
@@ -113,7 +113,6 @@ class CircuitListView(ObjectListView):
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
-    edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
     template_name = 'circuits/circuit_list.html'
 
 
@@ -136,7 +135,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.CircuitForm
     fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
-    obj_list_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -150,21 +149,23 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.CircuitImportForm
     table = tables.CircuitTable
     template_name = 'circuits/circuit_import.html'
-    obj_list_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_circuit'
     cls = Circuit
+    filter = filters.CircuitFilter
     form = forms.CircuitBulkEditForm
     template_name = 'circuits/circuit_bulk_edit.html'
-    default_redirect_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     cls = Circuit
-    default_redirect_url = 'circuits:circuit_list'
+    filter = filters.CircuitFilter
+    default_return_url = 'circuits:circuit_list'
 
 
 @permission_required('circuits.change_circuittermination')
@@ -208,7 +209,7 @@ def circuit_terminations_swap(request, pk):
         'form': form,
         'panel_class': 'default',
         'button_class': 'primary',
-        'cancel_url': circuit.get_absolute_url(),
+        'return_url': circuit.get_absolute_url(),
     })
 
 

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

@@ -134,12 +134,13 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
 class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     manufacturer = ManufacturerNestedSerializer()
     subdevice_role = serializers.SerializerMethodField()
+    instance_count = serializers.IntegerField(source='instances.count', read_only=True)
 
     class Meta:
         model = DeviceType
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
                   'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
-                  'comments', 'custom_fields']
+                  'comments', 'custom_fields', 'instance_count']
 
     def get_subdevice_role(self, obj):
         return {

+ 4 - 3
netbox/dcim/api/views.py

@@ -331,7 +331,8 @@ class InterfaceListView(generics.ListAPIView):
     def get_queryset(self):
 
         device = get_object_or_404(Device, pk=self.kwargs['pk'])
-        queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
+        queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
+            .select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
 
         # Filter by type (physical or virtual)
         iface_type = self.request.query_params.get('type')
@@ -489,8 +490,8 @@ class RelatedConnectionsView(APIView):
             response['power-ports'].append(data)
 
         # Interface connections
-        interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
-                                                                            'circuit_termination')
+        interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
+            .select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
         for iface in interfaces:
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             del(data['device'])

+ 44 - 13
netbox/dcim/forms.py

@@ -13,7 +13,7 @@ from utilities.forms import (
     SlugField,
 )
 
-from formfields import MACAddressFormField
+from .formfields import MACAddressFormField
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
@@ -101,6 +101,7 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
+    q = forms.CharField(required=False, label='Search')
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
                                null_option=(0, 'None'))
 
@@ -232,6 +233,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Rack
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
     group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
                                  .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
@@ -281,6 +283,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = DeviceType
+    q = forms.CharField(required=False, label='Search')
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
                                      to_field_name='slug')
 
@@ -639,18 +642,46 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
-    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
-    rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
-                                      label='Rack Group')
-    role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
-    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
-                               null_option=(0, 'None'))
-    device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
-                                       .annotate(filter_count=Count('instances')), label='Type')
-    platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
-                                 to_field_name='slug', null_option=(0, 'None'))
-    status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
-    mac_address = forms.CharField(required=False, label='MAC address')
+    q = forms.CharField(required=False, label='Search')
+    site = FilterChoiceField(
+        queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
+        to_field_name='slug',
+    )
+    rack_group_id = FilterChoiceField(
+        queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
+        label='Rack Group',
+    )
+    role = FilterChoiceField(
+        queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
+        to_field_name='slug',
+    )
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
+        null_option=(0, 'None'),
+    )
+    manufacturer_id = FilterChoiceField(
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer',
+    )
+    device_type_id = FilterChoiceField(
+        queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
+            filter_count=Count('instances'),
+        ),
+        label='Model',
+    )
+    platform = FilterChoiceField(
+        queryset=Platform.objects.annotate(filter_count=Count('devices')),
+        to_field_name='slug',
+        null_option=(0, 'None'),
+    )
+    status = forms.NullBooleanField(
+        required=False,
+        widget=forms.Select(choices=FORM_STATUS_CHOICES),
+    )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address',
+    )
 
 
 #

+ 46 - 23
netbox/dcim/models.py

@@ -8,6 +8,7 @@ from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
+from django.utils.encoding import python_2_unicode_compatible
 
 from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
@@ -199,6 +200,7 @@ class SiteManager(NaturalOrderByManager):
         return self.natural_order_by('name')
 
 
+@python_2_unicode_compatible
 class Site(CreatedUpdatedModel, CustomFieldModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -222,7 +224,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -265,6 +267,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 # Racks
 #
 
+@python_2_unicode_compatible
 class RackGroup(models.Model):
     """
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -282,13 +285,14 @@ class RackGroup(models.Model):
             ['site', 'slug'],
         ]
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
 
+@python_2_unicode_compatible
 class RackRole(models.Model):
     """
     Racks can be organized by functional role, similar to Devices.
@@ -300,7 +304,7 @@ class RackRole(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -313,6 +317,7 @@ class RackManager(NaturalOrderByManager):
         return self.natural_order_by('site__name', 'name')
 
 
+@python_2_unicode_compatible
 class Rack(CreatedUpdatedModel, CustomFieldModel):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -343,7 +348,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             ['site', 'facility_id'],
         ]
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
 
     def get_absolute_url(self):
@@ -442,7 +447,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
         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)
+        units = list(range(1, self.u_height + 1))
 
         # Remove units consumed by installed devices
         for d in devices:
@@ -477,6 +482,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 # Device Types
 #
 
+@python_2_unicode_compatible
 class Manufacturer(models.Model):
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -487,13 +493,14 @@ class Manufacturer(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
 
 
+@python_2_unicode_compatible
 class DeviceType(models.Model, CustomFieldModel):
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -538,7 +545,7 @@ class DeviceType(models.Model, CustomFieldModel):
             ['manufacturer', 'slug'],
         ]
 
-    def __unicode__(self):
+    def __str__(self):
         return self.model
 
     def __init__(self, *args, **kwargs):
@@ -608,6 +615,7 @@ class DeviceType(models.Model, CustomFieldModel):
         return bool(self.subdevice_role is False)
 
 
+@python_2_unicode_compatible
 class ConsolePortTemplate(models.Model):
     """
     A template for a ConsolePort to be created for a new Device.
@@ -619,10 +627,11 @@ class ConsolePortTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class ConsoleServerPortTemplate(models.Model):
     """
     A template for a ConsoleServerPort to be created for a new Device.
@@ -634,10 +643,11 @@ class ConsoleServerPortTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class PowerPortTemplate(models.Model):
     """
     A template for a PowerPort to be created for a new Device.
@@ -649,10 +659,11 @@ class PowerPortTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class PowerOutletTemplate(models.Model):
     """
     A template for a PowerOutlet to be created for a new Device.
@@ -664,7 +675,7 @@ class PowerOutletTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
@@ -706,6 +717,7 @@ class InterfaceManager(models.Manager):
         }).order_by(*ordering)
 
 
+@python_2_unicode_compatible
 class InterfaceTemplate(models.Model):
     """
     A template for a physical data interface on a new Device.
@@ -721,10 +733,11 @@ class InterfaceTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class DeviceBayTemplate(models.Model):
     """
     A template for a DeviceBay to be created for a new parent Device.
@@ -736,7 +749,7 @@ class DeviceBayTemplate(models.Model):
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
@@ -744,6 +757,7 @@ class DeviceBayTemplate(models.Model):
 # Devices
 #
 
+@python_2_unicode_compatible
 class DeviceRole(models.Model):
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -756,13 +770,14 @@ class DeviceRole(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
 
 
+@python_2_unicode_compatible
 class Platform(models.Model):
     """
     Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
@@ -776,7 +791,7 @@ class Platform(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -789,6 +804,7 @@ class DeviceManager(NaturalOrderByManager):
         return self.natural_order_by('name')
 
 
+@python_2_unicode_compatible
 class Device(CreatedUpdatedModel, CustomFieldModel):
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -828,7 +844,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         ordering = ['name']
         unique_together = ['rack', 'position', 'face']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
 
     def get_absolute_url(self):
@@ -968,6 +984,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         return RPC_CLIENTS.get(self.platform.rpc_client)
 
 
+@python_2_unicode_compatible
 class ConsolePort(models.Model):
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -982,7 +999,7 @@ class ConsolePort(models.Model):
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     # Used for connections export
@@ -1011,6 +1028,7 @@ class ConsoleServerPortManager(models.Manager):
         }).order_by('device', 'name_as_integer')
 
 
+@python_2_unicode_compatible
 class ConsoleServerPort(models.Model):
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -1023,10 +1041,11 @@ class ConsoleServerPort(models.Model):
     class Meta:
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class PowerPort(models.Model):
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -1041,7 +1060,7 @@ class PowerPort(models.Model):
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     # Used for connections export
@@ -1064,6 +1083,7 @@ class PowerOutletManager(models.Manager):
         }).order_by('device', 'name_padded')
 
 
+@python_2_unicode_compatible
 class PowerOutlet(models.Model):
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -1076,10 +1096,11 @@ class PowerOutlet(models.Model):
     class Meta:
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
 
+@python_2_unicode_compatible
 class Interface(models.Model):
     """
     A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
@@ -1099,7 +1120,7 @@ class Interface(models.Model):
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def clean(self):
@@ -1176,6 +1197,7 @@ class InterfaceConnection(models.Model):
         ])
 
 
+@python_2_unicode_compatible
 class DeviceBay(models.Model):
     """
     An empty space within a Device which can house a child device
@@ -1189,7 +1211,7 @@ class DeviceBay(models.Model):
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.device.name, self.name)
 
     def clean(self):
@@ -1205,6 +1227,7 @@ class DeviceBay(models.Model):
             raise ValidationError("Cannot install a device into itself.")
 
 
+@python_2_unicode_compatible
 class Module(models.Model):
     """
     A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
@@ -1223,5 +1246,5 @@ class Module(models.Model):
         ordering = ['device__id', 'parent__id', 'name']
         unique_together = ['device', 'parent', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name

+ 38 - 37
netbox/dcim/tests/test_apis.py

@@ -65,7 +65,7 @@ class SiteTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -75,7 +75,7 @@ class SiteTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -84,9 +84,9 @@ class SiteTest(APITestCase):
 
     def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        for i in json.loads(response.content):
+        for i in json.loads(response.content.decode('utf-8')):
             self.assertEqual(
                 sorted(i.keys()),
                 sorted(self.rack_fields),
@@ -99,9 +99,9 @@ class SiteTest(APITestCase):
 
     def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        for i in json.loads(response.content):
+        for i in json.loads(response.content.decode('utf-8')):
             self.assertEqual(
                 sorted(i.keys()),
                 sorted(self.graph_fields),
@@ -159,7 +159,7 @@ class RackTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -173,7 +173,7 @@ class RackTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -202,7 +202,7 @@ class ManufacturersTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -212,7 +212,7 @@ class ManufacturersTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -239,6 +239,7 @@ class DeviceTypeTest(APITestCase):
         'subdevice_role',
         'comments',
         'custom_fields',
+        'instance_count',
     ]
 
     nested_fields = [
@@ -250,7 +251,7 @@ class DeviceTypeTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -261,7 +262,7 @@ class DeviceTypeTest(APITestCase):
     def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
         # TODO: details returns list view.
         # response = self.client.get(endpoint)
-        # content = json.loads(response.content)
+        # content = json.loads(response.content.decode('utf-8'))
         # self.assertEqual(response.status_code, status.HTTP_200_OK)
         # self.assertEqual(
         #     sorted(content.keys()),
@@ -284,7 +285,7 @@ class DeviceRolesTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -294,7 +295,7 @@ class DeviceRolesTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -312,7 +313,7 @@ class PlatformsTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -322,7 +323,7 @@ class PlatformsTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -360,7 +361,7 @@ class DeviceTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for device in content:
             self.assertEqual(
@@ -425,7 +426,7 @@ class DeviceTest(APITestCase):
         ]
 
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         device = content[0]
         self.assertEqual(
@@ -435,7 +436,7 @@ class DeviceTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -453,7 +454,7 @@ class ConsoleServerPortsTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for console_port in content:
             self.assertEqual(
@@ -475,7 +476,7 @@ class ConsolePortsTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for console_port in content:
             self.assertEqual(
@@ -493,7 +494,7 @@ class ConsolePortsTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -514,7 +515,7 @@ class PowerPortsTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -528,7 +529,7 @@ class PowerPortsTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -549,7 +550,7 @@ class PowerOutletsTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -599,7 +600,7 @@ class InterfaceTest(APITestCase):
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         for i in content:
             self.assertEqual(
@@ -613,7 +614,7 @@ class InterfaceTest(APITestCase):
 
     def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -625,19 +626,19 @@ class InterfaceTest(APITestCase):
         )
 
     def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
-            response = self.client.get(endpoint)
-            content = json.loads(response.content)
-            self.assertEqual(response.status_code, status.HTTP_200_OK)
-            for i in content:
-                self.assertEqual(
-                    sorted(i.keys()),
-                    sorted(SiteTest.graph_fields),
-                )
+        response = self.client.get(endpoint)
+        content = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        for i in content:
+            self.assertEqual(
+                sorted(i.keys()),
+                sorted(SiteTest.graph_fields),
+            )
 
     def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
                                        .format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),
@@ -659,7 +660,7 @@ class RelatedConnectionsTest(APITestCase):
     def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
                                       .format(settings.BASE_PATH))):
         response = self.client.get(endpoint)
-        content = json.loads(response.content)
+        content = json.loads(response.content.decode('utf-8'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
             sorted(content.keys()),

+ 75 - 66
netbox/dcim/views.py

@@ -71,7 +71,7 @@ class ComponentCreateView(View):
             'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'form': self.form(initial=request.GET),
-            'cancel_url': parent.get_absolute_url(),
+            'return_url': parent.get_absolute_url(),
         })
 
     def post(self, request, pk):
@@ -112,10 +112,22 @@ class ComponentCreateView(View):
             'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'form': form,
-            'cancel_url': parent.get_absolute_url(),
+            'return_url': parent.get_absolute_url(),
         })
 
 
+class ComponentEditView(ObjectEditView):
+
+    def get_return_url(self, obj):
+        return obj.device.get_absolute_url()
+
+
+class ComponentDeleteView(ObjectDeleteView):
+
+    def get_return_url(self, obj):
+        return obj.device.get_absolute_url()
+
+
 #
 # Sites
 #
@@ -125,7 +137,6 @@ class SiteListView(ObjectListView):
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
-    edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
     template_name = 'dcim/site_list.html'
 
 
@@ -157,7 +168,7 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
     model = Site
     form_class = forms.SiteForm
     template_name = 'dcim/site_edit.html'
-    obj_list_url = 'dcim:site_list'
+    default_return_url = 'dcim:site_list'
 
 
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -171,15 +182,16 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.SiteImportForm
     table = tables.SiteTable
     template_name = 'dcim/site_import.html'
-    obj_list_url = 'dcim:site_list'
+    default_return_url = 'dcim:site_list'
 
 
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     cls = Site
+    filter = filters.SiteFilter
     form = forms.SiteBulkEditForm
     template_name = 'dcim/site_bulk_edit.html'
-    default_redirect_url = 'dcim:site_list'
+    default_return_url = 'dcim:site_list'
 
 
 #
@@ -191,7 +203,6 @@ class RackGroupListView(ObjectListView):
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
-    edit_permissions = ['dcim.change_rackgroup', 'dcim.delete_rackgroup']
     template_name = 'dcim/rackgroup_list.html'
 
 
@@ -207,7 +218,8 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     cls = RackGroup
-    default_redirect_url = 'dcim:rackgroup_list'
+    filter = filters.RackGroupFilter
+    default_return_url = 'dcim:rackgroup_list'
 
 
 #
@@ -217,7 +229,6 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackRoleListView(ObjectListView):
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
-    edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
     template_name = 'dcim/rackrole_list.html'
 
 
@@ -233,7 +244,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackrole'
     cls = RackRole
-    default_redirect_url = 'dcim:rackrole_list'
+    default_return_url = 'dcim:rackrole_list'
 
 
 #
@@ -246,7 +257,6 @@ class RackListView(ObjectListView):
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     table = tables.RackTable
-    edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
     template_name = 'dcim/rack_list.html'
 
 
@@ -274,7 +284,7 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
     model = Rack
     form_class = forms.RackForm
     template_name = 'dcim/rack_edit.html'
-    obj_list_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -288,21 +298,23 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.RackImportForm
     table = tables.RackImportTable
     template_name = 'dcim/rack_import.html'
-    obj_list_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rack'
     cls = Rack
+    filter = filters.RackFilter
     form = forms.RackBulkEditForm
     template_name = 'dcim/rack_bulk_edit.html'
-    default_redirect_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rack'
     cls = Rack
-    default_redirect_url = 'dcim:rack_list'
+    filter = filters.RackFilter
+    default_return_url = 'dcim:rack_list'
 
 
 #
@@ -312,7 +324,6 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ManufacturerListView(ObjectListView):
     queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
     table = tables.ManufacturerTable
-    edit_permissions = ['dcim.change_manufacturer', 'dcim.delete_manufacturer']
     template_name = 'dcim/manufacturer_list.html'
 
 
@@ -328,7 +339,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_manufacturer'
     cls = Manufacturer
-    default_redirect_url = 'dcim:manufacturer_list'
+    default_return_url = 'dcim:manufacturer_list'
 
 
 #
@@ -340,7 +351,6 @@ class DeviceTypeListView(ObjectListView):
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
-    edit_permissions = ['dcim.change_devicetype', 'dcim.delete_devicetype']
     template_name = 'dcim/devicetype_list.html'
 
 
@@ -398,7 +408,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
     model = DeviceType
     form_class = forms.DeviceTypeForm
     template_name = 'dcim/devicetype_edit.html'
-    obj_list_url = 'dcim:devicetype_list'
+    default_return_url = 'dcim:devicetype_list'
 
 
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -410,15 +420,17 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_devicetype'
     cls = DeviceType
+    filter = filters.DeviceTypeFilter
     form = forms.DeviceTypeBulkEditForm
     template_name = 'dcim/devicetype_bulk_edit.html'
-    default_redirect_url = 'dcim:devicetype_list'
+    default_return_url = 'dcim:devicetype_list'
 
 
 class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicetype'
     cls = DeviceType
-    default_redirect_url = 'dcim:devicetype_list'
+    filter = filters.DeviceTypeFilter
+    default_return_url = 'dcim:devicetype_list'
 
 
 #
@@ -532,7 +544,6 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceRoleListView(ObjectListView):
     queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
     table = tables.DeviceRoleTable
-    edit_permissions = ['dcim.change_devicerole', 'dcim.delete_devicerole']
     template_name = 'dcim/devicerole_list.html'
 
 
@@ -548,7 +559,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicerole'
     cls = DeviceRole
-    default_redirect_url = 'dcim:devicerole_list'
+    default_return_url = 'dcim:devicerole_list'
 
 
 #
@@ -558,7 +569,6 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PlatformListView(ObjectListView):
     queryset = Platform.objects.annotate(device_count=Count('devices'))
     table = tables.PlatformTable
-    edit_permissions = ['dcim.change_platform', 'dcim.delete_platform']
     template_name = 'dcim/platform_list.html'
 
 
@@ -574,7 +584,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_platform'
     cls = Platform
-    default_redirect_url = 'dcim:platform_list'
+    default_return_url = 'dcim:platform_list'
 
 
 #
@@ -587,7 +597,6 @@ class DeviceListView(ObjectListView):
     filter = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
     table = tables.DeviceTable
-    edit_permissions = ['dcim.change_device', 'dcim.delete_device']
     template_name = 'dcim/device_list.html'
 
 
@@ -666,7 +675,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.DeviceForm
     fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -680,7 +689,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.DeviceImportForm
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -688,7 +697,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.ChildDeviceImportForm
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import_child.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
     def save_obj(self, obj):
         # Inherent rack from parent device
@@ -703,15 +712,17 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_device'
     cls = Device
+    filter = filters.DeviceFilter
     form = forms.DeviceBulkEditForm
     template_name = 'dcim/device_bulk_edit.html'
-    default_redirect_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_device'
     cls = Device
-    default_redirect_url = 'dcim:device_list'
+    filter = filters.DeviceFilter
+    default_return_url = 'dcim:device_list'
 
 
 def device_inventory(request, pk):
@@ -729,7 +740,8 @@ def device_inventory(request, pk):
 def device_lldp_neighbors(request, pk):
 
     device = get_object_or_404(Device, pk=pk)
-    interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
+    interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
+        .select_related('connected_as_a', 'connected_as_b')
 
     return render(request, 'dcim/device_lldp_neighbors.html', {
         'device': device,
@@ -776,7 +788,7 @@ def consoleport_connect(request, pk):
     return render(request, 'dcim/consoleport_connect.html', {
         'consoleport': consoleport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
     })
 
 
@@ -805,17 +817,17 @@ def consoleport_disconnect(request, pk):
     return render(request, 'dcim/consoleport_disconnect.html', {
         'consoleport': consoleport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}),
     })
 
 
-class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
+class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_consoleport'
     model = ConsolePort
     form_class = forms.ConsolePortForm
 
 
-class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleport'
     model = ConsolePort
 
@@ -872,7 +884,7 @@ def consoleserverport_connect(request, pk):
     return render(request, 'dcim/consoleserverport_connect.html', {
         'consoleserverport': consoleserverport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
     })
 
 
@@ -902,17 +914,17 @@ def consoleserverport_disconnect(request, pk):
     return render(request, 'dcim/consoleserverport_disconnect.html', {
         'consoleserverport': consoleserverport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}),
     })
 
 
-class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
+class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_consoleserverport'
     model = ConsoleServerPort
     form_class = forms.ConsoleServerPortForm
 
 
-class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     model = ConsoleServerPort
 
@@ -962,7 +974,7 @@ def powerport_connect(request, pk):
     return render(request, 'dcim/powerport_connect.html', {
         'powerport': powerport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
     })
 
 
@@ -991,17 +1003,17 @@ def powerport_disconnect(request, pk):
     return render(request, 'dcim/powerport_disconnect.html', {
         'powerport': powerport,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}),
     })
 
 
-class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
+class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_powerport'
     model = PowerPort
     form_class = forms.PowerPortForm
 
 
-class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_powerport'
     model = PowerPort
 
@@ -1058,7 +1070,7 @@ def poweroutlet_connect(request, pk):
     return render(request, 'dcim/poweroutlet_connect.html', {
         'poweroutlet': poweroutlet,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
     })
 
 
@@ -1087,17 +1099,17 @@ def poweroutlet_disconnect(request, pk):
     return render(request, 'dcim/poweroutlet_disconnect.html', {
         'poweroutlet': poweroutlet,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}),
     })
 
 
-class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
+class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_poweroutlet'
     model = PowerOutlet
     form_class = forms.PowerOutletForm
 
 
-class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     model = PowerOutlet
 
@@ -1121,13 +1133,13 @@ class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
     model_form = forms.InterfaceForm
 
 
-class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
+class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_interface'
     model = Interface
     form_class = forms.InterfaceForm
 
 
-class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_interface'
     model = Interface
 
@@ -1159,13 +1171,13 @@ class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
     model_form = forms.DeviceBayForm
 
 
-class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
+class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_devicebay'
     model = DeviceBay
     form_class = forms.DeviceBayForm
 
 
-class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_devicebay'
     model = DeviceBay
 
@@ -1192,7 +1204,7 @@ def devicebay_populate(request, pk):
     return render(request, 'dcim/devicebay_populate.html', {
         'device_bay': device_bay,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
     })
 
 
@@ -1216,7 +1228,7 @@ def devicebay_depopulate(request, pk):
     return render(request, 'dcim/devicebay_depopulate.html', {
         'device_bay': device_bay,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}),
     })
 
 
@@ -1245,7 +1257,7 @@ class DeviceBulkAddComponentView(View):
 
         # 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]
+            pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())]
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
@@ -1291,7 +1303,7 @@ class DeviceBulkAddComponentView(View):
             'form': form,
             'component_name': self.model._meta.verbose_name_plural,
             'selected_devices': selected_devices,
-            'cancel_url': reverse('dcim:device_list'),
+            'return_url': reverse('dcim:device_list'),
         })
 
 
@@ -1373,7 +1385,7 @@ def interfaceconnection_add(request, pk):
     return render(request, 'dcim/interfaceconnection_edit.html', {
         'device': device,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
 
 
@@ -1405,15 +1417,15 @@ def interfaceconnection_delete(request, pk):
 
     # Determine where to direct user upon cancellation
     if device_id:
-        cancel_url = reverse('dcim:device', kwargs={'pk': device_id})
+        return_url = reverse('dcim:device', kwargs={'pk': device_id})
     else:
-        cancel_url = reverse('dcim:device_list')
+        return_url = reverse('dcim:device_list')
 
     return render(request, 'dcim/interfaceconnection_delete.html', {
         'interfaceconnection': interfaceconnection,
         'device_id': device_id,
         'form': form,
-        'cancel_url': cancel_url,
+        'return_url': return_url,
     })
 
 
@@ -1492,7 +1504,7 @@ def ipaddress_assign(request, pk):
     return render(request, 'dcim/ipaddress_assign.html', {
         'device': device,
         'form': form,
-        'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}),
+        'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
     })
 
 
@@ -1500,7 +1512,7 @@ def ipaddress_assign(request, pk):
 # Modules
 #
 
-class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
+class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_module'
     model = Module
     form_class = forms.ModuleForm
@@ -1510,10 +1522,7 @@ class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
             obj.device = get_object_or_404(Device, pk=kwargs['device'])
         return obj
 
-    def get_return_url(self, obj):
-        return obj.device.get_absolute_url()
-
 
-class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_module'
     model = Module

+ 1 - 1
netbox/extras/api/renderers.py

@@ -50,7 +50,7 @@ class FlatJSONRenderer(renderers.BaseRenderer):
     def render(self, data, media_type=None, renderer_context=None):
 
         def flatten(entry):
-            for key, val in entry.iteritems():
+            for key, val in entry.items():
                 if isinstance(val, dict):
                     for child_key, child_val in flatten(val):
                         yield "{}_{}".format(key, child_key), child_val

+ 15 - 7
netbox/extras/models.py

@@ -8,6 +8,7 @@ from django.core.validators import ValidationError
 from django.db import models
 from django.http import HttpResponse
 from django.template import Template, Context
+from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 
 
@@ -93,6 +94,7 @@ class CustomFieldModel(object):
             return OrderedDict([(field, None) for field in fields])
 
 
+@python_2_unicode_compatible
 class CustomField(models.Model):
     obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
                                       limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -114,7 +116,7 @@ class CustomField(models.Model):
     class Meta:
         ordering = ['weight', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.label or self.name.replace('_', ' ').capitalize()
 
     def serialize_value(self, value):
@@ -153,6 +155,7 @@ class CustomField(models.Model):
         return serialized_value
 
 
+@python_2_unicode_compatible
 class CustomFieldValue(models.Model):
     field = models.ForeignKey('CustomField', related_name='values')
     obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
@@ -164,7 +167,7 @@ class CustomFieldValue(models.Model):
         ordering = ['obj_type', 'obj_id']
         unique_together = ['field', 'obj_type', 'obj_id']
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} {}'.format(self.obj, self.field)
 
     @property
@@ -183,6 +186,7 @@ class CustomFieldValue(models.Model):
             super(CustomFieldValue, self).save(*args, **kwargs)
 
 
+@python_2_unicode_compatible
 class CustomFieldChoice(models.Model):
     field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
                               on_delete=models.CASCADE)
@@ -193,7 +197,7 @@ class CustomFieldChoice(models.Model):
         ordering = ['field', 'weight', 'value']
         unique_together = ['field', 'value']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.value
 
     def clean(self):
@@ -207,6 +211,7 @@ class CustomFieldChoice(models.Model):
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
 
 
+@python_2_unicode_compatible
 class Graph(models.Model):
     type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
     weight = models.PositiveSmallIntegerField(default=1000)
@@ -217,7 +222,7 @@ class Graph(models.Model):
     class Meta:
         ordering = ['type', 'weight', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def embed_url(self, obj):
@@ -231,6 +236,7 @@ class Graph(models.Model):
         return template.render(Context({'obj': obj}))
 
 
+@python_2_unicode_compatible
 class ExportTemplate(models.Model):
     content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
     name = models.CharField(max_length=100)
@@ -245,7 +251,7 @@ class ExportTemplate(models.Model):
             ['content_type', 'name']
         ]
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{}: {}'.format(self.content_type, self.name)
 
     def to_response(self, context_dict, filename):
@@ -264,6 +270,7 @@ class ExportTemplate(models.Model):
         return response
 
 
+@python_2_unicode_compatible
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
@@ -278,7 +285,7 @@ class TopologyMap(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     @property
@@ -328,6 +335,7 @@ class UserActionManager(models.Manager):
         self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
 
 
+@python_2_unicode_compatible
 class UserAction(models.Model):
     """
     A record of an action (add, edit, or delete) performed on an object by a User.
@@ -344,7 +352,7 @@ class UserAction(models.Model):
     class Meta:
         ordering = ['-time']
 
-    def __unicode__(self):
+    def __str__(self):
         if self.message:
             return u'{} {}'.format(self.user, self.message)
         return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)

+ 2 - 2
netbox/generate_secret_key.py

@@ -1,8 +1,8 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 import os
 import random
 
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
 random.seed = (os.urandom(2048))
-print ''.join(random.choice(charset) for c in range(50))
+print(''.join(random.choice(charset) for c in range(50)))

+ 8 - 3
netbox/ipam/forms.py

@@ -63,6 +63,7 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
+    q = forms.CharField(required=False, label='Search')
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
                                null_option=(0, None))
 
@@ -128,6 +129,7 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Aggregate
+    q = forms.CharField(required=False, label='Search')
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
                             label='RIR')
@@ -256,8 +258,9 @@ def prefix_status_choices():
 
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Prefix
-    parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
-        'placeholder': 'Network',
+    q = forms.CharField(required=False, label='Search')
+    parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
+        'placeholder': 'Prefix',
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
@@ -446,7 +449,8 @@ def ipaddress_status_choices():
 
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
-    parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
+    q = forms.CharField(required=False, label='Search')
+    parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
         'placeholder': 'Prefix',
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
@@ -560,6 +564,7 @@ def vlan_status_choices():
 
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VLAN
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
     group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
                                  null_option=(0, 'None'))

+ 20 - 0
netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-01-23 19:10
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0013_prefix_add_is_pool'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (3, b'Deprecated'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
+        ),
+    ]

+ 21 - 9
netbox/ipam/models.py

@@ -7,6 +7,7 @@ from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models.expressions import RawSQL
+from django.utils.encoding import python_2_unicode_compatible
 
 from dcim.models import Interface
 from extras.models import CustomFieldModel, CustomFieldValue
@@ -36,10 +37,12 @@ PREFIX_STATUS_CHOICES = (
 
 IPADDRESS_STATUS_ACTIVE = 1
 IPADDRESS_STATUS_RESERVED = 2
+IPADDRESS_STATUS_DEPRECATED = 3
 IPADDRESS_STATUS_DHCP = 5
 IPADDRESS_STATUS_CHOICES = (
     (IPADDRESS_STATUS_ACTIVE, 'Active'),
     (IPADDRESS_STATUS_RESERVED, 'Reserved'),
+    (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
     (IPADDRESS_STATUS_DHCP, 'DHCP')
 )
 
@@ -70,6 +73,7 @@ IP_PROTOCOL_CHOICES = (
 )
 
 
+@python_2_unicode_compatible
 class VRF(CreatedUpdatedModel, CustomFieldModel):
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -89,7 +93,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         verbose_name = 'VRF'
         verbose_name_plural = 'VRFs'
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -105,6 +109,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         ])
 
 
+@python_2_unicode_compatible
 class RIR(models.Model):
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -120,13 +125,14 @@ class RIR(models.Model):
         verbose_name = 'RIR'
         verbose_name_plural = 'RIRs'
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
 
 
+@python_2_unicode_compatible
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -142,7 +148,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
     class Meta:
         ordering = ['family', 'prefix']
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.prefix)
 
     def get_absolute_url(self):
@@ -204,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         return int(children_size / self.prefix.size * 100)
 
 
+@python_2_unicode_compatible
 class Role(models.Model):
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -216,7 +223,7 @@ class Role(models.Model):
     class Meta:
         ordering = ['weight', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     @property
@@ -263,6 +270,7 @@ class PrefixQuerySet(NullsFirstQuerySet):
         return filter(lambda p: p.depth <= limit, queryset)
 
 
+@python_2_unicode_compatible
 class Prefix(CreatedUpdatedModel, CustomFieldModel):
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -292,7 +300,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
         ordering = ['vrf', 'family', 'prefix']
         verbose_name_plural = 'prefixes'
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.prefix)
 
     def get_absolute_url(self):
@@ -377,6 +385,7 @@ class IPAddressManager(models.Manager):
         return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
 
 
+@python_2_unicode_compatible
 class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -409,7 +418,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
         verbose_name = 'IP address'
         verbose_name_plural = 'IP addresses'
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.address)
 
     def get_absolute_url(self):
@@ -469,6 +478,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
         return STATUS_CHOICE_CLASSES[self.status]
 
 
+@python_2_unicode_compatible
 class VLANGroup(models.Model):
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
@@ -486,13 +496,14 @@ class VLANGroup(models.Model):
         verbose_name = 'VLAN group'
         verbose_name_plural = 'VLAN groups'
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.site.name, self.name)
 
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
 
 
+@python_2_unicode_compatible
 class VLAN(CreatedUpdatedModel, CustomFieldModel):
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -524,7 +535,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
         verbose_name = 'VLAN'
         verbose_name_plural = 'VLANs'
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
 
     def get_absolute_url(self):
@@ -558,6 +569,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
         return STATUS_CHOICE_CLASSES[self.status]
 
 
+@python_2_unicode_compatible
 class Service(CreatedUpdatedModel):
     """
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
@@ -576,5 +588,5 @@ class Service(CreatedUpdatedModel):
         ordering = ['device', 'protocol', 'port']
         unique_together = ['device', 'protocol', 'port']
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

+ 2 - 1
netbox/ipam/tables.py

@@ -234,11 +234,12 @@ class PrefixBriefTable(BaseTable):
     vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
     role = tables.Column(verbose_name='Role')
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        fields = ('prefix', 'vrf', 'status', 'site', 'role')
+        fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
         orderable = False
 
 

+ 43 - 35
netbox/ipam/views.py

@@ -95,7 +95,6 @@ class VRFListView(ObjectListView):
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
     table = tables.VRFTable
-    edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     template_name = 'ipam/vrf_list.html'
 
 
@@ -118,7 +117,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
     model = VRF
     form_class = forms.VRFForm
     template_name = 'ipam/vrf_edit.html'
-    obj_list_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -132,21 +131,23 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.VRFImportForm
     table = tables.VRFTable
     template_name = 'ipam/vrf_import.html'
-    obj_list_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vrf'
     cls = VRF
+    filter = filters.VRFFilter
     form = forms.VRFBulkEditForm
     template_name = 'ipam/vrf_bulk_edit.html'
-    default_redirect_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vrf'
     cls = VRF
-    default_redirect_url = 'ipam:vrf_list'
+    filter = filters.VRFFilter
+    default_return_url = 'ipam:vrf_list'
 
 
 #
@@ -158,7 +159,6 @@ class RIRListView(ObjectListView):
     filter = filters.RIRFilter
     filter_form = forms.RIRFilterForm
     table = tables.RIRTable
-    edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
     template_name = 'ipam/rir_list.html'
 
     def alter_queryset(self, request):
@@ -250,7 +250,8 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_rir'
     cls = RIR
-    default_redirect_url = 'ipam:rir_list'
+    filter = filters.RIRFilter
+    default_return_url = 'ipam:rir_list'
 
 
 #
@@ -264,7 +265,6 @@ class AggregateListView(ObjectListView):
     filter = filters.AggregateFilter
     filter_form = forms.AggregateFilterForm
     table = tables.AggregateTable
-    edit_permissions = ['ipam.change_aggregate', 'ipam.delete_aggregate']
     template_name = 'ipam/aggregate_list.html'
 
     def extra_context(self):
@@ -308,7 +308,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
     model = Aggregate
     form_class = forms.AggregateForm
     template_name = 'ipam/aggregate_edit.html'
-    obj_list_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -322,21 +322,23 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.AggregateImportForm
     table = tables.AggregateTable
     template_name = 'ipam/aggregate_import.html'
-    obj_list_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_aggregate'
     cls = Aggregate
+    filter = filters.AggregateFilter
     form = forms.AggregateBulkEditForm
     template_name = 'ipam/aggregate_bulk_edit.html'
-    default_redirect_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_aggregate'
     cls = Aggregate
-    default_redirect_url = 'ipam:aggregate_list'
+    filter = filters.AggregateFilter
+    default_return_url = 'ipam:aggregate_list'
 
 
 #
@@ -346,7 +348,6 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RoleListView(ObjectListView):
     queryset = Role.objects.all()
     table = tables.RoleTable
-    edit_permissions = ['ipam.change_role', 'ipam.delete_role']
     template_name = 'ipam/role_list.html'
 
 
@@ -362,7 +363,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_role'
     cls = Role
-    default_redirect_url = 'ipam:role_list'
+    default_return_url = 'ipam:role_list'
 
 
 #
@@ -374,7 +375,6 @@ class PrefixListView(ObjectListView):
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
-    edit_permissions = ['ipam.change_prefix', 'ipam.delete_prefix']
     template_name = 'ipam/prefix_list.html'
 
     def alter_queryset(self, request):
@@ -401,11 +401,13 @@ def prefix(request, pk):
         .filter(prefix__net_contains=str(prefix.prefix))\
         .select_related('site', 'role').annotate_depth()
     parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
+    parent_prefix_table.exclude = ('vrf',)
 
     # Duplicate prefixes table
     duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
         .select_related('site', 'role')
     duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
+    duplicate_prefix_table.exclude = ('vrf',)
 
     # Child prefixes table
     if prefix.vrf:
@@ -430,6 +432,7 @@ def prefix(request, pk):
         'parent_prefix_table': parent_prefix_table,
         'child_prefix_table': child_prefix_table,
         'duplicate_prefix_table': duplicate_prefix_table,
+        'return_url': prefix.get_absolute_url(),
     })
 
 
@@ -439,14 +442,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
     fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
-    obj_list_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_prefix'
     model = Prefix
-    default_return_url = 'ipam:prefix_list'
     template_name = 'ipam/prefix_delete.html'
+    default_return_url = 'ipam:prefix_list'
 
 
 class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -454,21 +457,23 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.PrefixImportForm
     table = tables.PrefixTable
     template_name = 'ipam/prefix_import.html'
-    obj_list_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_prefix'
     cls = Prefix
+    filter = filters.PrefixFilter
     form = forms.PrefixBulkEditForm
     template_name = 'ipam/prefix_bulk_edit.html'
-    default_redirect_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_prefix'
     cls = Prefix
-    default_redirect_url = 'ipam:prefix_list'
+    filter = filters.PrefixFilter
+    default_return_url = 'ipam:prefix_list'
 
 
 def prefix_ipaddresses(request, pk):
@@ -500,7 +505,6 @@ class IPAddressListView(ObjectListView):
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
-    edit_permissions = ['ipam.change_ipaddress', 'ipam.delete_ipaddress']
     template_name = 'ipam/ipaddress_list.html'
 
 
@@ -562,7 +566,7 @@ def ipaddress_assign(request, pk):
     return render(request, 'ipam/ipaddress_assign.html', {
         'ipaddress': ipaddress,
         'form': form,
-        'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
+        'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
     })
 
 
@@ -595,7 +599,7 @@ def ipaddress_remove(request, pk):
     return render(request, 'ipam/ipaddress_unassign.html', {
         'ipaddress': ipaddress,
         'form': form,
-        'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
+        'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
     })
 
 
@@ -605,7 +609,7 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.IPAddressForm
     fields_initial = ['address', 'vrf']
     template_name = 'ipam/ipaddress_edit.html'
-    obj_list_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -619,7 +623,7 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
     form = forms.IPAddressBulkAddForm
     model = IPAddress
     template_name = 'ipam/ipaddress_bulk_add.html'
-    redirect_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -627,7 +631,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.IPAddressImportForm
     table = tables.IPAddressTable
     template_name = 'ipam/ipaddress_import.html'
-    obj_list_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
     def save_obj(self, obj):
         obj.save()
@@ -648,15 +652,17 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_ipaddress'
     cls = IPAddress
+    filter = filters.IPAddressFilter
     form = forms.IPAddressBulkEditForm
     template_name = 'ipam/ipaddress_bulk_edit.html'
-    default_redirect_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     cls = IPAddress
-    default_redirect_url = 'ipam:ipaddress_list'
+    filter = filters.IPAddressFilter
+    default_return_url = 'ipam:ipaddress_list'
 
 
 #
@@ -668,7 +674,6 @@ class VLANGroupListView(ObjectListView):
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
-    edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
     template_name = 'ipam/vlangroup_list.html'
 
 
@@ -684,7 +689,8 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     cls = VLANGroup
-    default_redirect_url = 'ipam:vlangroup_list'
+    filter = filters.VLANGroupFilter
+    default_return_url = 'ipam:vlangroup_list'
 
 
 #
@@ -696,7 +702,6 @@ class VLANListView(ObjectListView):
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     table = tables.VLANTable
-    edit_permissions = ['ipam.change_vlan', 'ipam.delete_vlan']
     template_name = 'ipam/vlan_list.html'
 
 
@@ -705,6 +710,7 @@ def vlan(request, pk):
     vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
     prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
     prefix_table = tables.PrefixBriefTable(list(prefixes))
+    prefix_table.exclude = ('vlan',)
 
     return render(request, 'ipam/vlan.html', {
         'vlan': vlan,
@@ -717,7 +723,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
     model = VLAN
     form_class = forms.VLANForm
     template_name = 'ipam/vlan_edit.html'
-    obj_list_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -731,21 +737,23 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.VLANImportForm
     table = tables.VLANTable
     template_name = 'ipam/vlan_import.html'
-    obj_list_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vlan'
     cls = VLAN
+    filter = filters.VLANFilter
     form = forms.VLANBulkEditForm
     template_name = 'ipam/vlan_bulk_edit.html'
-    default_redirect_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlan'
     cls = VLAN
-    default_redirect_url = 'ipam:vlan_list'
+    filter = filters.VLANFilter
+    default_return_url = 'ipam:vlan_list'
 
 
 #

+ 3 - 3
netbox/netbox/settings.py

@@ -6,13 +6,13 @@ from django.contrib.messages import constants as messages
 from django.core.exceptions import ImproperlyConfigured
 
 try:
-    import configuration
+    from netbox import configuration
 except ImportError:
     raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
                                "the documentation.")
 
 
-VERSION = '1.8.2'
+VERSION = '1.8.3'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -50,7 +50,7 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 # Attempt to import LDAP configuration if it has been defined
 LDAP_IGNORE_CERT_ERRORS = False
 try:
-    from ldap_config import *
+    from netbox.ldap_config import *
     LDAP_CONFIGURED = True
 except ImportError:
     LDAP_CONFIGURED = False

+ 1 - 1
netbox/netbox/urls.py

@@ -2,7 +2,7 @@ from django.conf import settings
 from django.conf.urls import include, url
 from django.contrib import admin
 
-from views import home, handle_500, trigger_500
+from netbox.views import home, handle_500, trigger_500
 from users.views import login, logout
 
 

+ 8 - 0
netbox/project-static/js/forms.js

@@ -9,6 +9,14 @@ $(document).ready(function() {
             $('#select_all').prop('checked', false);
         }
     });
+    // Enable hidden buttons when "select all" is checked
+    $('#select_all').click(function (event) {
+        if ($(this).is(':checked')) {
+            $('#select_all_box').find('button').prop('disabled', '');
+        } else {
+            $('#select_all_box').find('button').prop('disabled', 'disabled');
+        }
+    });
     // Uncheck the "toggle all" checkbox if an item is unchecked
     $('input:checkbox[name=pk]').click(function (event) {
         if (!$(this).attr('checked')) {

+ 2 - 2
netbox/project-static/js/secrets.js

@@ -48,7 +48,7 @@ $(document).ready(function() {
     $('#generate_keypair').click(function() {
         $('#new_keypair_modal').modal('show');
         $.ajax({
-            url: '/api/secrets/generate-keys/',
+            url: netbox_api_path + 'secrets/generate-keys/',
             type: 'GET',
             dataType: 'json',
             success: function (response, status) {
@@ -75,7 +75,7 @@ $(document).ready(function() {
     function unlock_secret(secret_id, private_key) {
         var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
         $.ajax({
-            url: '/api/secrets/secrets/' + secret_id + '/',
+            url: netbox_api_path + 'secrets/secrets/' + secret_id + '/',
             type: 'POST',
             data: {
                 private_key: private_key

+ 1 - 0
netbox/secrets/forms.py

@@ -100,6 +100,7 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
 
 
 class SecretFilterForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(required=False, label='Search')
     role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
 
 

+ 7 - 4
netbox/secrets/models.py

@@ -8,7 +8,7 @@ from django.contrib.auth.models import Group, User
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.db import models
-from django.utils.encoding import force_bytes
+from django.utils.encoding import force_bytes, python_2_unicode_compatible
 
 from dcim.models import Device
 from utilities.models import CreatedUpdatedModel
@@ -51,6 +51,7 @@ class UserKeyQuerySet(models.QuerySet):
         raise Exception("Bulk deletion has been disabled.")
 
 
+@python_2_unicode_compatible
 class UserKey(CreatedUpdatedModel):
     """
     A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
@@ -76,7 +77,7 @@ class UserKey(CreatedUpdatedModel):
         self.__initial_public_key = self.public_key
         self.__initial_master_key_cipher = self.master_key_cipher
 
-    def __unicode__(self):
+    def __str__(self):
         return self.user.username
 
     def clean(self, *args, **kwargs):
@@ -170,6 +171,7 @@ class UserKey(CreatedUpdatedModel):
         self.save()
 
 
+@python_2_unicode_compatible
 class SecretRole(models.Model):
     """
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
@@ -186,7 +188,7 @@ class SecretRole(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
@@ -201,6 +203,7 @@ class SecretRole(models.Model):
         return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
 
 
+@python_2_unicode_compatible
 class Secret(CreatedUpdatedModel):
     """
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
@@ -227,7 +230,7 @@ class Secret(CreatedUpdatedModel):
         self.plaintext = kwargs.pop('plaintext', None)
         super(Secret, self).__init__(*args, **kwargs)
 
-    def __unicode__(self):
+    def __str__(self):
         if self.role and self.device:
             return u'{} for {}'.format(self.role, self.device)
         return u'Secret'

+ 0 - 1
netbox/secrets/tests/__init__.py

@@ -1 +0,0 @@
-from test_models import *

+ 8 - 8
netbox/secrets/views.py

@@ -22,7 +22,6 @@ from .models import SecretRole, Secret, UserKey
 class SecretRoleListView(ObjectListView):
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
-    edit_permissions = ['secrets.change_secretrole', 'secrets.delete_secretrole']
     template_name = 'secrets/secretrole_list.html'
 
 
@@ -38,7 +37,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secretrole'
     cls = SecretRole
-    default_redirect_url = 'secrets:secretrole_list'
+    default_return_url = 'secrets:secretrole_list'
 
 
 #
@@ -51,7 +50,6 @@ class SecretListView(ObjectListView):
     filter = filters.SecretFilter
     filter_form = forms.SecretFilterForm
     table = tables.SecretTable
-    edit_permissions = ['secrets.change_secret', 'secrets.delete_secret']
     template_name = 'secrets/secret_list.html'
 
 
@@ -103,7 +101,7 @@ def secret_add(request, pk):
     return render(request, 'secrets/secret_edit.html', {
         'secret': secret,
         'form': form,
-        'cancel_url': device.get_absolute_url(),
+        'return_url': device.get_absolute_url(),
     })
 
 
@@ -145,7 +143,7 @@ def secret_edit(request, pk):
     return render(request, 'secrets/secret_edit.html', {
         'secret': secret,
         'form': form,
-        'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
+        'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
     })
 
 
@@ -195,19 +193,21 @@ def secret_import(request):
 
     return render(request, 'secrets/secret_import.html', {
         'form': form,
-        'cancel_url': reverse('secrets:secret_list'),
+        'return_url': reverse('secrets:secret_list'),
     })
 
 
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'secrets.change_secret'
     cls = Secret
+    filter = filters.SecretFilter
     form = forms.SecretBulkEditForm
     template_name = 'secrets/secret_bulk_edit.html'
-    default_redirect_url = 'secrets:secret_list'
+    default_return_url = 'secrets:secret_list'
 
 
 class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secret'
     cls = Secret
-    default_redirect_url = 'secrets:secret_list'
+    filter = filters.SecretFilter
+    default_return_url = 'secrets:secret_list'

+ 3 - 0
netbox/templates/_base.html

@@ -296,6 +296,9 @@
             </div>
 		</div>
 	</footer>
+<script type="text/javascript">
+    var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
+</script>
 <script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.11.4/jquery-ui.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.6-dist/js/bootstrap.min.js' %}"></script>

+ 1 - 1
netbox/templates/circuits/circuit_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/circuits/circuit_list.html

@@ -24,7 +24,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/circuits/circuittermination_edit.html

@@ -83,7 +83,7 @@
                 {% else %}
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                 {% endif %}
-                <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
             </div>
         </div>
     </form>

+ 1 - 1
netbox/templates/circuits/provider_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/circuits/provider_list.html

@@ -23,7 +23,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -19,7 +19,7 @@
         {% render_table table 'table.html' %}
     </div>
     <div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -40,7 +40,7 @@
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
             </div>
         </div>

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

@@ -40,7 +40,7 @@
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
             </div>
         </div>

+ 4 - 4
netbox/templates/dcim/device.html

@@ -550,7 +550,7 @@
 function toggleConnection(elem, api_url) {
     if (elem.hasClass('connected')) {
         $.ajax({
-            url: api_url + elem.attr('data') + "/",
+            url: netbox_api_path + api_url + elem.attr('data') + "/",
             method: 'PATCH',
             dataType: 'json',
             beforeSend: function(xhr, settings) {
@@ -590,13 +590,13 @@ function toggleConnection(elem, api_url) {
     return false;
 }
 $(".consoleport-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
+    return toggleConnection($(this), "dcim/console-ports/");
 });
 $(".powerport-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
+    return toggleConnection($(this), "dcim/power-ports/");
 });
 $(".interface-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
+    return toggleConnection($(this), "dcim/interface-connections/");
 });
 </script>
 <script src="{% static 'js/graphs.js' %}"></script>

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

@@ -5,8 +5,8 @@
 <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 }}" />
+    {% if request.POST.return_url %}
+        <input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
     {% endif %}
     {% for field in form.hidden_fields %}
         {{ field }}
@@ -51,7 +51,7 @@
 		    <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>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -34,7 +34,7 @@
                 <div class="col-md-9 col-md-offset-3">
                     <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>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
             <div class="form-group">
                 <button type="submit" class="btn btn-primary">Submit</button>
-                <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+                <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
             </div>
 		</form>
 		<h4>CSV Format</h4>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
             <div class="form-group">
                 <button type="submit" class="btn btn-primary">Submit</button>
-                <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+                <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
             </div>
 		</form>
 		<h4>CSV Format</h4>

+ 25 - 1
netbox/templates/dcim/device_list.html

@@ -24,7 +24,31 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+$(document).ready(function() {
+    var model_list = $('#id_device_type_id');
+    $('#id_manufacturer_id').change(function() {
+        model_list.empty();
+        var selected_manufacturers = $(this).val();
+        if (selected_manufacturers) {
+            var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id=');
+            $.ajax({
+                url: api_url,
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response, function (index, device_type) {
+                        var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")");
+                        model_list.append(option);
+                    });
+                }
+            });
+        }
+    });
+});
+</script>
+{% endblock %}

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

@@ -37,7 +37,7 @@
 		    <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Save</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -33,7 +33,7 @@
 		    <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Save</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -19,7 +19,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

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

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

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

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

@@ -7,12 +7,12 @@
                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             <ul class="dropdown-menu">
-                {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}" class="formaction">Console Ports</a></li>{% endif %}
-                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}" class="formaction">Console Server Ports</a></li>{% endif %}
-                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}" class="formaction">Power Ports</a></li>{% endif %}
-                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}" class="formaction">Power Outlets</a></li>{% endif %}
-                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}" class="formaction">Interfaces</a></li>{% endif %}
-                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}" class="formaction">Device Bays</a></li>{% endif %}
+                {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
+                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
+                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
+                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
+                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
             </ul>
         </div>
     {% endif %}

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

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

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

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

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

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

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

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

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

@@ -19,7 +19,7 @@
         {% render_table table 'table.html' %}
     </div>
     <div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -86,7 +86,7 @@
     <div class="form-group">
         <button type="submit" name="_create" class="btn btn-primary">Connect</button>
         <button type="submit" name="_addanother" class="btn btn-primary">Connect and Add Another</button>
-        <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
     </div>
 </div>
 </form>

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

@@ -53,7 +53,7 @@
                 <div class="col-md-9 col-md-offset-3">
                     <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>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -19,7 +19,7 @@
         {% render_table table 'table.html' %}
     </div>
     <div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -40,7 +40,7 @@
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
             </div>
         </div>

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

@@ -40,7 +40,7 @@
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
-		            <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+		            <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
             </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-                <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+                <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

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

@@ -24,7 +24,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -18,7 +18,7 @@
         {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-                <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+                <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

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

@@ -23,7 +23,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 0 - 32
netbox/templates/inc/filter_panel.html

@@ -1,32 +0,0 @@
-{% load form_helpers %}
-
-{% if filter_form %}
-    <div class="panel panel-default">
-        <div class="panel-heading">
-            <span class="fa fa-filter" aria-hidden="true"></span>
-            <strong>Filter</strong>
-        </div>
-        <div class="panel-body">
-            <form action="." method="get" class="form">
-                {% for field in filter_form %}
-                    <div class="form-group">
-                        {% if field|widget_type == 'checkboxinput' %}
-                            <label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
-                        {% else %}
-                            {{ field.label_tag }}
-                            {{ field }}
-                        {% endif %}
-                    </div>
-                {% endfor %}
-                <div class="text-right">
-                    <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span> Apply
-                    </button>
-                    <a href="." class="btn btn-default">
-                        <span class="fa fa-remove" aria-hidden="true"></span> Clear
-                    </a>
-                </div>
-            </form>
-        </div>
-    </div>
-{% endif %}

+ 28 - 7
netbox/templates/inc/search_panel.html

@@ -1,18 +1,39 @@
+{% load form_helpers %}
+
 <div class="panel panel-default">
     <div class="panel-heading">
         <span class="fa fa-search" aria-hidden="true"></span>
         <strong>Search</strong>
     </div>
     <div class="panel-body">
-        <form action="." method="get">
-            <div class="input-group">
-                <input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
-                <span class="input-group-btn">
+        <form action="." method="get" class="form">
+                {% for field in filter_form %}
+                    <div class="form-group">
+                        {% if field.name == "q" %}
+                            <div class="input-group">
+                                <input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
+                                <span class="input-group-btn">
+                                    <button type="submit" class="btn btn-primary">
+                                        <span class="fa fa-search" aria-hidden="true"></span>
+                                    </button>
+                                </span>
+                            </div>
+                        {% elif field|widget_type == 'checkboxinput' %}
+                            <label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
+                        {% else %}
+                            {{ field.label_tag }}
+                            {{ field }}
+                        {% endif %}
+                    </div>
+                {% endfor %}
+                <div class="text-right">
                     <button type="submit" class="btn btn-primary">
-                        <span class="fa fa-search" aria-hidden="true"></span>
+                        <span class="fa fa-search" aria-hidden="true"></span> Apply
                     </button>
-                </span>
-            </div>
+                    <a href="." class="btn btn-default">
+                        <span class="fa fa-remove" aria-hidden="true"></span> Clear
+                    </a>
+                </div>
         </form>
     </div>
 </div>

+ 1 - 1
netbox/templates/ipam/aggregate_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/ipam/aggregate_list.html

@@ -27,7 +27,6 @@
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/ipaddress_assign.html

@@ -59,7 +59,7 @@
 		    <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>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

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

@@ -1,5 +1,4 @@
 {% extends '_base.html' %}
-{% load render_table from django_tables2 %}
 {% load helpers %}
 
 {% block title %}IP Addresses{% endblock %}
@@ -25,7 +24,6 @@
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/prefix_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/ipam/prefix_list.html

@@ -34,7 +34,6 @@
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/rir_list.html

@@ -33,7 +33,7 @@
         {% endif %}
     </div>
 	<div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/vlan_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/ipam/vlan_list.html

@@ -25,7 +25,6 @@
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/vlangroup_list.html

@@ -18,7 +18,7 @@
         {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
     </div>
     <div class="col-md-3">
-		{% include 'inc/filter_panel.html' %}
+		{% include 'inc/search_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/ipam/vrf_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/ipam/vrf_list.html

@@ -25,7 +25,6 @@
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
 	</div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/secrets/secret_edit.html

@@ -59,7 +59,7 @@
                 {% else %}
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 {% endif %}
 		    </div>
         </div>

+ 1 - 1
netbox/templates/secrets/secret_import.html

@@ -22,7 +22,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+		        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/secrets/secret_list.html

@@ -19,7 +19,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 1 - 1
netbox/templates/tenancy/tenant_import.html

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
-		        <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+		        <a href="{% url return_url %}" class="btn btn-default">Cancel</a>
 		    </div>
 		</form>
 	</div>

+ 0 - 1
netbox/templates/tenancy/tenant_list.html

@@ -24,7 +24,6 @@
     </div>
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 3 - 3
netbox/templates/utilities/bulk_edit_form.html

@@ -5,8 +5,8 @@
 <h1>{% block title %}{% endblock %}</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 }}" />
+    {% if request.POST.return_url %}
+        <input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
     {% endif %}
     {% for field in form.hidden_fields %}
         {{ field }}
@@ -44,7 +44,7 @@
 		    <div class="form-group text-right">
                 <div class="col-md-12">
                     <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
-                    <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
 		    </div>
         </div>

+ 1 - 1
netbox/templates/utilities/confirm_bulk_delete.html

@@ -5,7 +5,7 @@
 
 {% block message %}
     <p>
-        Are you sure you want to delete these {{ obj_type_plural|default:"objects" }}{% if parent_obj %} from <a href="{{ parent_obj.get_absolute_url }}">{{ parent_obj }}</a>{% endif %}?
+        Are you sure you want to delete these {{ selected_objects|length }} {{ obj_type_plural|default:"objects" }}{% if parent_obj %} from <a href="{{ parent_obj.get_absolute_url }}">{{ parent_obj }}</a>{% endif %}?
     </p>
     <ul>
         {% for obj in selected_objects %}

+ 1 - 1
netbox/templates/utilities/confirmation_form.html

@@ -23,7 +23,7 @@
                     </div>
                     <div class="text-right">
                         <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
-                        <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                        <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                     </div>
                 </div>
             </div>

+ 1 - 1
netbox/templates/utilities/obj_edit.html

@@ -37,7 +37,7 @@
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
                 {% endif %}
-                <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
             </div>
         </div>
     </form>

+ 26 - 13
netbox/templates/utilities/obj_table.html

@@ -1,29 +1,42 @@
 {% load render_table from django_tables2 %}
 {% load helpers %}
-{% if table.model|user_can_change:request.user or table.model|user_can_delete:request.user %}
+{% if permissions.change or permissions.delete %}
     <form method="post" class="form form-horizontal">
         {% csrf_token %}
-        <input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
-        <input type="hidden" name="pk_all" value="{% for obj in table.data.queryset %}{{ obj.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
+        <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
         {% if table.paginator.num_pages > 1 %}
-            <div id="select_all_box" class="hidden alert alert-info">
-                <div class="checkbox-inline">
-                    <label for="select_all">
-                        <input type="checkbox" id="select_all" name="_all" />
-                        Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
-                    </label>
+            <div id="select_all_box" class="hidden panel panel-default">
+                <div class="panel-body">
+                    <div class="checkbox-inline">
+                        <label for="select_all">
+                            <input type="checkbox" id="select_all" name="_all" />
+                            Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                        </label>
+                    </div>
+                    <div class="pull-right">
+                        {% if bulk_edit_url and permissions.change %}
+                            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit All
+                            </button>
+                        {% endif %}
+                        {% if bulk_delete_url and permissions.delete %}
+                            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete All
+                            </button>
+                        {% endif %}
+                    </div>
                 </div>
             </div>
         {% endif %}
         {% render_table table table_template|default:'table.html' %}
         {% block extra_actions %}{% endblock %}
-        {% if bulk_edit_url and table.model|user_can_change:request.user %}
-            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
+        {% if bulk_edit_url and permissions.change %}
+            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
             </button>
         {% endif %}
-        {% if bulk_delete_url and table.model|user_can_delete:request.user %}
-            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
+        {% if bulk_delete_url and permissions.delete %}
+            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
             </button>
         {% endif %}

+ 1 - 0
netbox/tenancy/forms.py

@@ -55,5 +55,6 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Tenant
+    q = forms.CharField(required=False, label='Search')
     group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
                               to_field_name='slug', null_option=(0, 'None'))

+ 5 - 2
netbox/tenancy/models.py

@@ -1,12 +1,14 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.urlresolvers import reverse
 from django.db import models
+from django.utils.encoding import python_2_unicode_compatible
 
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
 from utilities.utils import csv_format
 
 
+@python_2_unicode_compatible
 class TenantGroup(models.Model):
     """
     An arbitrary collection of Tenants.
@@ -17,13 +19,14 @@ class TenantGroup(models.Model):
     class Meta:
         ordering = ['name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):
         return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
 
 
+@python_2_unicode_compatible
 class Tenant(CreatedUpdatedModel, CustomFieldModel):
     """
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
@@ -39,7 +42,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
     class Meta:
         ordering = ['group', 'name']
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
 
     def get_absolute_url(self):

+ 8 - 8
netbox/tenancy/views.py

@@ -10,7 +10,7 @@ from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 
-from models import Tenant, TenantGroup
+from .models import Tenant, TenantGroup
 from . import filters, forms, tables
 
 
@@ -21,7 +21,6 @@ from . import filters, forms, tables
 class TenantGroupListView(ObjectListView):
     queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
     table = tables.TenantGroupTable
-    edit_permissions = ['tenancy.change_tenantgroup', 'tenancy.delete_tenantgroup']
     template_name = 'tenancy/tenantgroup_list.html'
 
 
@@ -37,7 +36,7 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenantgroup'
     cls = TenantGroup
-    default_redirect_url = 'tenancy:tenantgroup_list'
+    default_return_url = 'tenancy:tenantgroup_list'
 
 
 #
@@ -49,7 +48,6 @@ class TenantListView(ObjectListView):
     filter = filters.TenantFilter
     filter_form = forms.TenantFilterForm
     table = tables.TenantTable
-    edit_permissions = ['tenancy.change_tenant', 'tenancy.delete_tenant']
     template_name = 'tenancy/tenant_list.html'
 
 
@@ -85,7 +83,7 @@ class TenantEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.TenantForm
     fields_initial = ['group']
     template_name = 'tenancy/tenant_edit.html'
-    obj_list_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -99,18 +97,20 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.TenantImportForm
     table = tables.TenantTable
     template_name = 'tenancy/tenant_import.html'
-    obj_list_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'tenancy.change_tenant'
     cls = Tenant
+    filter = filters.TenantFilter
     form = forms.TenantBulkEditForm
     template_name = 'tenancy/tenant_bulk_edit.html'
-    default_redirect_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenant'
     cls = Tenant
-    default_redirect_url = 'tenancy:tenant_list'
+    filter = filters.TenantFilter
+    default_return_url = 'tenancy:tenant_list'

+ 31 - 13
netbox/utilities/forms.py

@@ -37,20 +37,38 @@ COLOR_CHOICES = (
     ('607d8b', 'Dark grey'),
     ('111111', 'Black'),
 )
-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})\]'
+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 parse_numeric_range(string, base=10):
+    """
+    Expand a numeric range (continuous or not) into a decimal or
+    hexadecimal list, as specified by the base parameter
+      '0-3,5' => [0, 1, 2, 3, 5]
+      '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
+    """
+    values = list()
+    for dash_range in string.split(','):
+        try:
+            begin, end = dash_range.split('-')
+        except ValueError:
+            begin, end = dash_range, dash_range
+        begin, end = int(begin.strip()), int(end.strip(), base=base) + 1
+        values.extend(range(begin, end))
+    return list(set(values))
 
 
 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']
+      'ge-0/0/[0-3,5]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3', 'ge-0/0/5']
+      'xe-0/[0,2-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(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
-    x, y = pattern.split('-')
-    for i in range(int(x), int(y) + 1):
+    parsed_range = parse_numeric_range(pattern)
+    for i in parsed_range:
         if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
             for string in expand_numeric_pattern(remnant):
                 yield "{}{}{}".format(lead, i, string)
@@ -61,8 +79,8 @@ def expand_numeric_pattern(string):
 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']
+      '192.0.2.[1,2,100-250,254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24', '192.0.2.254/24']
+      '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
     """
     if family not in [4, 6]:
         raise Exception("Invalid IP address family: {}".format(family))
@@ -73,8 +91,8 @@ def expand_ipaddress_pattern(string, family):
         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):
+    parsed_range = parse_numeric_range(pattern, base)
+    for i in parsed_range:
         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])
@@ -248,7 +266,7 @@ class ExpandableNameField(forms.CharField):
         super(ExpandableNameField, self).__init__(*args, **kwargs)
         if not self.help_text:
             self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
-                             'Example: <code>ge-0/0/[0-47]</code>'
+                             'Example: <code>ge-0/0/[0-23,25,30]</code>'
 
     def to_python(self, value):
         if re.search(NUMERIC_EXPANSION_PATTERN, value):
@@ -265,7 +283,7 @@ class ExpandableIPAddressField(forms.CharField):
         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>'
+                             'Example: <code>192.0.2.[1,5,100-254]/24</code>'
 
     def to_python(self, value):
         # Hackish address family detection but it's all we have to work with

+ 0 - 4
netbox/utilities/tables.py

@@ -17,10 +17,6 @@ class BaseTable(tables.Table):
             'class': 'table table-hover',
         }
 
-    @property
-    def model(self):
-        return self._meta.model
-
 
 class ToggleColumn(tables.CheckBoxColumn):
 

+ 0 - 18
netbox/utilities/templatetags/helpers.py

@@ -44,24 +44,6 @@ def startswith(value, arg):
     return str(value).startswith(arg)
 
 
-@register.filter()
-def user_can_add(model, user):
-    perm_name = '{}:add_{}'.format(model._meta.app_label, model.__class__.__name__.lower())
-    return user.has_perm(perm_name)
-
-
-@register.filter()
-def user_can_change(model, user):
-    perm_name = '{}:change_{}'.format(model._meta.app_label, model.__class__.__name__.lower())
-    return user.has_perm(perm_name)
-
-
-@register.filter()
-def user_can_delete(model, user):
-    perm_name = '{}:delete_{}'.format(model._meta.app_label, model.__class__.__name__.lower())
-    return user.has_perm(perm_name)
-
-
 #
 # Tags
 #

+ 58 - 55
netbox/utilities/views.py

@@ -3,7 +3,7 @@ from django_tables2 import RequestConfig
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ImproperlyConfigured, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.db import transaction, IntegrityError
 from django.db.models import ProtectedError
@@ -46,14 +46,12 @@ class ObjectListView(View):
     filter: A django-filter FilterSet that is applied to the queryset
     filter_form: The form used to render filter options
     table: The django-tables2 Table used to render the objects list
-    edit_permissions: Editing controls are displayed only if the user has these permissions
     template_name: The name of the template
     """
     queryset = None
     filter = None
     filter_form = None
     table = None
-    edit_permissions = []
     template_name = None
 
     def get(self, request):
@@ -95,14 +93,19 @@ class ObjectListView(View):
         # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
         self.queryset = self.alter_queryset(request)
 
+        # Compile user model permissions for access from within the template
+        perm_base_name = '{}.{{}}_{}'.format(model._meta.app_label, model._meta.model_name)
+        permissions = {p: request.user.has_perm(perm_base_name.format(p)) for p in ['add', 'change', 'delete']}
+
         # Construct the table based on the user's permissions
         table = self.table(self.queryset)
-        if 'pk' in table.base_columns and any([request.user.has_perm(perm) for perm in self.edit_permissions]):
+        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
             table.base_columns['pk'].visible = True
         RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(table)
 
         context = {
             'table': table,
+            'permissions': permissions,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
             'export_templates': ExportTemplate.objects.filter(content_type=object_ct),
         }
@@ -126,13 +129,13 @@ class ObjectEditView(View):
     form_class: The form used to create or edit the object
     fields_initial: A set of fields that will be prepopulated in the form from the request parameters
     template_name: The name of the template
-    obj_list_url: The name of the URL used to display a list of this object type
+    default_return_url: The name of the URL used to display a list of this object type
     """
     model = None
     form_class = None
     fields_initial = []
     template_name = 'utilities/obj_edit.html'
-    obj_list_url = None
+    default_return_url = 'home'
 
     def get_object(self, kwargs):
         # Look up object by slug or PK. Return None if neither was provided.
@@ -151,9 +154,7 @@ class ObjectEditView(View):
         # Determine where to redirect the user after updating an object (or aborting an update).
         if obj.pk and hasattr(obj, 'get_absolute_url'):
             return obj.get_absolute_url()
-        if self.obj_list_url is not None:
-            return reverse(self.obj_list_url)
-        return reverse('home')
+        return reverse(self.default_return_url)
 
     def get(self, request, *args, **kwargs):
 
@@ -166,7 +167,7 @@ class ObjectEditView(View):
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
-            'cancel_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(obj),
         })
 
     def post(self, request, *args, **kwargs):
@@ -203,7 +204,7 @@ class ObjectEditView(View):
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
-            'cancel_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(obj),
         })
 
 
@@ -226,10 +227,10 @@ class ObjectDeleteView(View):
         else:
             return get_object_or_404(self.model, pk=kwargs['pk'])
 
-    def get_cancel_url(self, obj):
+    def get_return_url(self, obj):
         if hasattr(obj, 'get_absolute_url'):
             return obj.get_absolute_url()
-        return reverse('home')
+        return reverse(self.default_return_url)
 
     def get(self, request, **kwargs):
 
@@ -243,7 +244,7 @@ class ObjectDeleteView(View):
             'obj': obj,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
-            'cancel_url': request.GET.get('return_url') or self.get_cancel_url(obj),
+            'return_url': request.GET.get('return_url') or self.get_return_url(obj),
         })
 
     def post(self, request, **kwargs):
@@ -272,7 +273,7 @@ class ObjectDeleteView(View):
             'obj': obj,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
-            'cancel_url': request.GET.get('return_url') or self.get_cancel_url(obj),
+            'return_url': request.GET.get('return_url') or self.get_return_url(obj),
         })
 
 
@@ -283,12 +284,12 @@ class BulkAddView(View):
     form: Form class
     model: The model of the objects being created
     template_name: The name of the template
-    redirect_url: Name of the URL to which the user is redirected after creating the objects
+    default_return_url: Name of the URL to which the user is redirected after creating the objects
     """
     form = None
     model = None
     template_name = None
-    redirect_url = None
+    default_return_url = 'home'
 
     def get(self, request):
 
@@ -297,7 +298,7 @@ class BulkAddView(View):
         return render(request, self.template_name, {
             'obj_type': self.model._meta.verbose_name,
             'form': form,
-            'cancel_url': reverse(self.redirect_url),
+            'return_url': reverse(self.default_return_url),
         })
 
     def post(self, request):
@@ -328,12 +329,12 @@ class BulkAddView(View):
                 messages.success(request, u"Added {} {}.".format(len(new_objs), self.model._meta.verbose_name_plural))
                 if '_addanother' in request.POST:
                     return redirect(request.path)
-                return redirect(self.redirect_url)
+                return redirect(self.default_return_url)
 
         return render(request, self.template_name, {
             'form': form,
             'obj_type': self.model._meta.verbose_name,
-            'cancel_url': reverse(self.redirect_url),
+            'return_url': reverse(self.default_return_url),
         })
 
 
@@ -344,18 +345,18 @@ class BulkImportView(View):
     form: Form class
     table: The django-tables2 Table used to render the list of imported objects
     template_name: The name of the template
-    obj_list_url: The name of the URL to use for the cancel button
+    default_return_url: The name of the URL to use for the cancel button
     """
     form = None
     table = None
     template_name = None
-    obj_list_url = None
+    default_return_url = None
 
     def get(self, request):
 
         return render(request, self.template_name, {
             'form': self.form(),
-            'obj_list_url': self.obj_list_url,
+            'return_url': self.default_return_url,
         })
 
     def post(self, request):
@@ -384,7 +385,7 @@ class BulkImportView(View):
 
         return render(request, self.template_name, {
             'form': form,
-            'obj_list_url': self.obj_list_url,
+            'return_url': self.default_return_url,
         })
 
     def save_obj(self, obj):
@@ -397,18 +398,21 @@ class BulkEditView(View):
 
     cls: The model of the objects being edited
     parent_cls: The model of the parent object (if any)
+    filter: FilterSet to apply when deleting by QuerySet
     form: The form class used to edit objects in bulk
     template_name: The name of the template
-    default_redirect_url: Name of the URL to which the user is redirected after editing the objects
+    default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overriden by
+                        POSTing return_url)
     """
     cls = None
     parent_cls = None
+    filter = None
     form = None
     template_name = None
-    default_redirect_url = None
+    default_return_url = 'home'
 
     def get(self):
-        return redirect(self.default_redirect_url)
+        return redirect(self.default_return_url)
 
     def post(self, request, **kwargs):
 
@@ -419,19 +423,17 @@ class BulkEditView(View):
             parent_obj = None
 
         # Determine URL to redirect users upon modification of objects
-        posted_redirect_url = request.POST.get('redirect_url')
-        if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()):
-            redirect_url = posted_redirect_url
+        posted_return_url = request.POST.get('return_url')
+        if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
+            return_url = posted_return_url
         elif parent_obj:
-            redirect_url = parent_obj.get_absolute_url()
-        elif self.default_redirect_url:
-            redirect_url = reverse(self.default_redirect_url)
+            return_url = parent_obj.get_absolute_url()
         else:
-            raise ImproperlyConfigured('No redirect URL has been provided.')
+            return_url = reverse(self.default_return_url)
 
         # 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]
+        if request.POST.get('_all') and self.filter is not None:
+            pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk'))]
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
@@ -465,7 +467,7 @@ class BulkEditView(View):
                     msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
                     messages.success(self.request, msg)
                     UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
-                return redirect(redirect_url)
+                return redirect(return_url)
 
         else:
             form = self.form(self.cls, initial={'pk': pk_list})
@@ -473,12 +475,12 @@ class BulkEditView(View):
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects:
             messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural))
-            return redirect(redirect_url)
+            return redirect(return_url)
 
         return render(request, self.template_name, {
             'form': form,
             'selected_objects': selected_objects,
-            'cancel_url': redirect_url,
+            'return_url': return_url,
         })
 
     def update_custom_fields(self, pk_list, form, fields, nullified_fields):
@@ -535,15 +537,18 @@ class BulkDeleteView(View):
 
     cls: The model of the objects being deleted
     parent_cls: The model of the parent object (if any)
+    filter: FilterSet to apply when deleting by QuerySet
     form: The form class used to delete objects in bulk
     template_name: The name of the template
-    default_redirect_url: Name of the URL to which the user is redirected after deleting the objects
+    default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by
+                        POSTing return_url)
     """
     cls = None
     parent_cls = None
+    filter = None
     form = None
     template_name = 'utilities/confirm_bulk_delete.html'
-    default_redirect_url = None
+    default_return_url = 'home'
 
     def post(self, request, **kwargs):
 
@@ -554,19 +559,17 @@ class BulkDeleteView(View):
             parent_obj = None
 
         # Determine URL to redirect users upon deletion of objects
-        posted_redirect_url = request.POST.get('redirect_url')
-        if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()):
-            redirect_url = posted_redirect_url
+        posted_return_url = request.POST.get('return_url')
+        if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()):
+            return_url = posted_return_url
         elif parent_obj:
-            redirect_url = parent_obj.get_absolute_url()
-        elif self.default_redirect_url:
-            redirect_url = reverse(self.default_redirect_url)
+            return_url = parent_obj.get_absolute_url()
         else:
-            raise ImproperlyConfigured('No redirect URL has been provided.')
+            return_url = reverse(self.default_return_url)
 
         # Are we deleting *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]
+        if request.POST.get('_all') and self.filter is not None:
+            pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk'))]
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
@@ -582,27 +585,27 @@ class BulkDeleteView(View):
                     deleted_count = queryset.delete()[1][self.cls._meta.label]
                 except ProtectedError as e:
                     handle_protectederror(list(queryset), request, e)
-                    return redirect(redirect_url)
+                    return redirect(return_url)
 
                 msg = u'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
                 messages.success(request, msg)
                 UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
-                return redirect(redirect_url)
+                return redirect(return_url)
 
         else:
-            form = form_cls(initial={'pk': pk_list})
+            form = form_cls(initial={'pk': pk_list, 'return_url': return_url})
 
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects:
             messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
-            return redirect(redirect_url)
+            return redirect(return_url)
 
         return render(request, self.template_name, {
             'form': form,
             'parent_obj': parent_obj,
             'obj_type_plural': self.cls._meta.verbose_name_plural,
             'selected_objects': selected_objects,
-            'cancel_url': redirect_url,
+            'return_url': return_url,
         })
 
     def get_form(self):