Преглед изворни кода

Merge pull request #849 from digitalocean/develop

Release v1.8.3
Jeremy Stretch пре 9 година
родитељ
комит
c90cecc2fb
99 измењених фајлова са 662 додато и 459 уклоњено
  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
 *.pyc
 /netbox/netbox/configuration.py
 /netbox/netbox/configuration.py
+/netbox/netbox/ldap_config.py
 /netbox/static
 /netbox/static
 .idea
 .idea
 /*.sh
 /*.sh
 !upgrade.sh
 !upgrade.sh
 fabfile.py
 fabfile.py
 *.swp
 *.swp
+gunicorn_config.py

+ 3 - 0
.travis.yml

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

+ 17 - 0
docs/installation/netbox.md

@@ -2,12 +2,29 @@
 
 
 **Debian/Ubuntu**
 **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
 ```no-highlight
 # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
 # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
 ```
 ```
 
 
 **CentOS/RHEL**
 **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
 ```no-highlight
 # yum install -y epel-release
 # yum install -y epel-release
 # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
 # 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):
 class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Provider
     model = Provider
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
 
 
 
 
@@ -126,6 +127,7 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
     model = Circuit
+    q = forms.CharField(required=False, label='Search')
     type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
     type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
                              to_field_name='slug')
                              to_field_name='slug')
     provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
     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.contrib.contenttypes.fields import GenericRelation
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.db import models
 from django.db import models
+from django.utils.encoding import python_2_unicode_compatible
 
 
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
@@ -33,6 +34,7 @@ def humanize_speed(speed):
         return '{} Kbps'.format(speed)
         return '{} Kbps'.format(speed)
 
 
 
 
+@python_2_unicode_compatible
 class Provider(CreatedUpdatedModel, CustomFieldModel):
 class Provider(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -67,6 +69,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
         ])
         ])
 
 
 
 
+@python_2_unicode_compatible
 class CircuitType(models.Model):
 class CircuitType(models.Model):
     """
     """
     Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
 
 
 
 
+@python_2_unicode_compatible
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     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']
         ordering = ['provider', 'cid']
         unique_together = ['provider', 'cid']
         unique_together = ['provider', 'cid']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} {}'.format(self.provider, self.cid)
         return u'{} {}'.format(self.provider, self.cid)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -141,6 +145,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     commit_rate_human.admin_order_field = 'commit_rate'
     commit_rate_human.admin_order_field = 'commit_rate'
 
 
 
 
+@python_2_unicode_compatible
 class CircuitTermination(models.Model):
 class CircuitTermination(models.Model):
     circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
     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')
     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']
         ordering = ['circuit', 'term_side']
         unique_together = ['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())
         return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
 
 
     def get_peer_termination(self):
     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 = filters.ProviderFilter
     filter_form = forms.ProviderFilterForm
     filter_form = forms.ProviderFilterForm
     table = tables.ProviderTable
     table = tables.ProviderTable
-    edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
     template_name = 'circuits/provider_list.html'
     template_name = 'circuits/provider_list.html'
 
 
 
 
@@ -47,7 +46,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
     model = Provider
     model = Provider
     form_class = forms.ProviderForm
     form_class = forms.ProviderForm
     template_name = 'circuits/provider_edit.html'
     template_name = 'circuits/provider_edit.html'
-    obj_list_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 
 
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -61,21 +60,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.ProviderImportForm
     form = forms.ProviderImportForm
     table = tables.ProviderTable
     table = tables.ProviderTable
     template_name = 'circuits/provider_import.html'
     template_name = 'circuits/provider_import.html'
-    obj_list_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 
 
 class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_provider'
     permission_required = 'circuits.change_provider'
     cls = Provider
     cls = Provider
+    filter = filters.ProviderFilter
     form = forms.ProviderBulkEditForm
     form = forms.ProviderBulkEditForm
     template_name = 'circuits/provider_bulk_edit.html'
     template_name = 'circuits/provider_bulk_edit.html'
-    default_redirect_url = 'circuits:provider_list'
+    default_return_url = 'circuits:provider_list'
 
 
 
 
 class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_provider'
     permission_required = 'circuits.delete_provider'
     cls = 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):
 class CircuitTypeListView(ObjectListView):
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
-    edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
     template_name = 'circuits/circuittype_list.html'
     template_name = 'circuits/circuittype_list.html'
 
 
 
 
@@ -101,7 +101,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     permission_required = 'circuits.delete_circuittype'
     cls = 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 = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
-    edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
     template_name = 'circuits/circuit_list.html'
     template_name = 'circuits/circuit_list.html'
 
 
 
 
@@ -136,7 +135,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.CircuitForm
     form_class = forms.CircuitForm
     fields_initial = ['provider']
     fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     template_name = 'circuits/circuit_edit.html'
-    obj_list_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 
 
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -150,21 +149,23 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.CircuitImportForm
     form = forms.CircuitImportForm
     table = tables.CircuitTable
     table = tables.CircuitTable
     template_name = 'circuits/circuit_import.html'
     template_name = 'circuits/circuit_import.html'
-    obj_list_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 
 
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
     cls = Circuit
     cls = Circuit
+    filter = filters.CircuitFilter
     form = forms.CircuitBulkEditForm
     form = forms.CircuitBulkEditForm
     template_name = 'circuits/circuit_bulk_edit.html'
     template_name = 'circuits/circuit_bulk_edit.html'
-    default_redirect_url = 'circuits:circuit_list'
+    default_return_url = 'circuits:circuit_list'
 
 
 
 
 class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     permission_required = 'circuits.delete_circuit'
     cls = Circuit
     cls = Circuit
-    default_redirect_url = 'circuits:circuit_list'
+    filter = filters.CircuitFilter
+    default_return_url = 'circuits:circuit_list'
 
 
 
 
 @permission_required('circuits.change_circuittermination')
 @permission_required('circuits.change_circuittermination')
@@ -208,7 +209,7 @@ def circuit_terminations_swap(request, pk):
         'form': form,
         'form': form,
         'panel_class': 'default',
         'panel_class': 'default',
         'button_class': 'primary',
         '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):
 class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     manufacturer = ManufacturerNestedSerializer()
     manufacturer = ManufacturerNestedSerializer()
     subdevice_role = serializers.SerializerMethodField()
     subdevice_role = serializers.SerializerMethodField()
+    instance_count = serializers.IntegerField(source='instances.count', read_only=True)
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
                   'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
                   '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):
     def get_subdevice_role(self, obj):
         return {
         return {

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

@@ -331,7 +331,8 @@ class InterfaceListView(generics.ListAPIView):
     def get_queryset(self):
     def get_queryset(self):
 
 
         device = get_object_or_404(Device, pk=self.kwargs['pk'])
         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)
         # Filter by type (physical or virtual)
         iface_type = self.request.query_params.get('type')
         iface_type = self.request.query_params.get('type')
@@ -489,8 +490,8 @@ class RelatedConnectionsView(APIView):
             response['power-ports'].append(data)
             response['power-ports'].append(data)
 
 
         # Interface connections
         # 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:
         for iface in interfaces:
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             del(data['device'])
             del(data['device'])

+ 44 - 13
netbox/dcim/forms.py

@@ -13,7 +13,7 @@ from utilities.forms import (
     SlugField,
     SlugField,
 )
 )
 
 
-from formfields import MACAddressFormField
+from .formfields import MACAddressFormField
 from .models import (
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
@@ -101,6 +101,7 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
     model = Site
+    q = forms.CharField(required=False, label='Search')
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
                                null_option=(0, 'None'))
                                null_option=(0, 'None'))
 
 
@@ -232,6 +233,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Rack
     model = Rack
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
     group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
     group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
                                  .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
                                  .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
@@ -281,6 +283,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = DeviceType
     model = DeviceType
+    q = forms.CharField(required=False, label='Search')
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
                                      to_field_name='slug')
                                      to_field_name='slug')
 
 
@@ -639,18 +642,46 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     model = Device
-    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
-    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.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 from django.db.models import Count, Q, ObjectDoesNotExist
+from django.utils.encoding import python_2_unicode_compatible
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
@@ -199,6 +200,7 @@ class SiteManager(NaturalOrderByManager):
         return self.natural_order_by('name')
         return self.natural_order_by('name')
 
 
 
 
+@python_2_unicode_compatible
 class Site(CreatedUpdatedModel, CustomFieldModel):
 class Site(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -265,6 +267,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 # Racks
 # Racks
 #
 #
 
 
+@python_2_unicode_compatible
 class RackGroup(models.Model):
 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
     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'],
             ['site', 'slug'],
         ]
         ]
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.site.name, self.name)
         return u'{} - {}'.format(self.site.name, self.name)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
 
 
 
+@python_2_unicode_compatible
 class RackRole(models.Model):
 class RackRole(models.Model):
     """
     """
     Racks can be organized by functional role, similar to Devices.
     Racks can be organized by functional role, similar to Devices.
@@ -300,7 +304,7 @@ class RackRole(models.Model):
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -313,6 +317,7 @@ class RackManager(NaturalOrderByManager):
         return self.natural_order_by('site__name', 'name')
         return self.natural_order_by('site__name', 'name')
 
 
 
 
+@python_2_unicode_compatible
 class Rack(CreatedUpdatedModel, CustomFieldModel):
 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.
     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'],
             ['site', 'facility_id'],
         ]
         ]
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
         return self.display_name
 
 
     def get_absolute_url(self):
     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)
         devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
 
 
         # Initialize the rack unit skeleton
         # Initialize the rack unit skeleton
-        units = range(1, self.u_height + 1)
+        units = list(range(1, self.u_height + 1))
 
 
         # Remove units consumed by installed devices
         # Remove units consumed by installed devices
         for d in devices:
         for d in devices:
@@ -477,6 +482,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 # Device Types
 # Device Types
 #
 #
 
 
+@python_2_unicode_compatible
 class Manufacturer(models.Model):
 class Manufacturer(models.Model):
     """
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -487,13 +493,14 @@ class Manufacturer(models.Model):
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
 
 
 
 
+@python_2_unicode_compatible
 class DeviceType(models.Model, CustomFieldModel):
 class DeviceType(models.Model, CustomFieldModel):
     """
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
     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'],
             ['manufacturer', 'slug'],
         ]
         ]
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.model
         return self.model
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -608,6 +615,7 @@ class DeviceType(models.Model, CustomFieldModel):
         return bool(self.subdevice_role is False)
         return bool(self.subdevice_role is False)
 
 
 
 
+@python_2_unicode_compatible
 class ConsolePortTemplate(models.Model):
 class ConsolePortTemplate(models.Model):
     """
     """
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
@@ -619,10 +627,11 @@ class ConsolePortTemplate(models.Model):
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class ConsoleServerPortTemplate(models.Model):
 class ConsoleServerPortTemplate(models.Model):
     """
     """
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
@@ -634,10 +643,11 @@ class ConsoleServerPortTemplate(models.Model):
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class PowerPortTemplate(models.Model):
 class PowerPortTemplate(models.Model):
     """
     """
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
@@ -649,10 +659,11 @@ class PowerPortTemplate(models.Model):
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class PowerOutletTemplate(models.Model):
 class PowerOutletTemplate(models.Model):
     """
     """
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
@@ -664,7 +675,7 @@ class PowerOutletTemplate(models.Model):
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
@@ -706,6 +717,7 @@ class InterfaceManager(models.Manager):
         }).order_by(*ordering)
         }).order_by(*ordering)
 
 
 
 
+@python_2_unicode_compatible
 class InterfaceTemplate(models.Model):
 class InterfaceTemplate(models.Model):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
@@ -721,10 +733,11 @@ class InterfaceTemplate(models.Model):
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class DeviceBayTemplate(models.Model):
 class DeviceBayTemplate(models.Model):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     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']
         ordering = ['device_type', 'name']
         unique_together = ['device_type', 'name']
         unique_together = ['device_type', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
@@ -744,6 +757,7 @@ class DeviceBayTemplate(models.Model):
 # Devices
 # Devices
 #
 #
 
 
+@python_2_unicode_compatible
 class DeviceRole(models.Model):
 class DeviceRole(models.Model):
     """
     """
     Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
         return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
 
 
 
 
+@python_2_unicode_compatible
 class Platform(models.Model):
 class Platform(models.Model):
     """
     """
     Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -789,6 +804,7 @@ class DeviceManager(NaturalOrderByManager):
         return self.natural_order_by('name')
         return self.natural_order_by('name')
 
 
 
 
+@python_2_unicode_compatible
 class Device(CreatedUpdatedModel, CustomFieldModel):
 class Device(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     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']
         ordering = ['name']
         unique_together = ['rack', 'position', 'face']
         unique_together = ['rack', 'position', 'face']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
         return self.display_name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -968,6 +984,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         return RPC_CLIENTS.get(self.platform.rpc_client)
         return RPC_CLIENTS.get(self.platform.rpc_client)
 
 
 
 
+@python_2_unicode_compatible
 class ConsolePort(models.Model):
 class ConsolePort(models.Model):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -982,7 +999,7 @@ class ConsolePort(models.Model):
         ordering = ['device', 'name']
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     # Used for connections export
     # Used for connections export
@@ -1011,6 +1028,7 @@ class ConsoleServerPortManager(models.Manager):
         }).order_by('device', 'name_as_integer')
         }).order_by('device', 'name_as_integer')
 
 
 
 
+@python_2_unicode_compatible
 class ConsoleServerPort(models.Model):
 class ConsoleServerPort(models.Model):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     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:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class PowerPort(models.Model):
 class PowerPort(models.Model):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -1041,7 +1060,7 @@ class PowerPort(models.Model):
         ordering = ['device', 'name']
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     # Used for connections export
     # Used for connections export
@@ -1064,6 +1083,7 @@ class PowerOutletManager(models.Manager):
         }).order_by('device', 'name_padded')
         }).order_by('device', 'name_padded')
 
 
 
 
+@python_2_unicode_compatible
 class PowerOutlet(models.Model):
 class PowerOutlet(models.Model):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -1076,10 +1096,11 @@ class PowerOutlet(models.Model):
     class Meta:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
 
 
+@python_2_unicode_compatible
 class Interface(models.Model):
 class Interface(models.Model):
     """
     """
     A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
     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']
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def clean(self):
     def clean(self):
@@ -1176,6 +1197,7 @@ class InterfaceConnection(models.Model):
         ])
         ])
 
 
 
 
+@python_2_unicode_compatible
 class DeviceBay(models.Model):
 class DeviceBay(models.Model):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
@@ -1189,7 +1211,7 @@ class DeviceBay(models.Model):
         ordering = ['device', 'name']
         ordering = ['device', 'name']
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.device.name, self.name)
         return u'{} - {}'.format(self.device.name, self.name)
 
 
     def clean(self):
     def clean(self):
@@ -1205,6 +1227,7 @@ class DeviceBay(models.Model):
             raise ValidationError("Cannot install a device into itself.")
             raise ValidationError("Cannot install a device into itself.")
 
 
 
 
+@python_2_unicode_compatible
 class Module(models.Model):
 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
     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']
         ordering = ['device__id', 'parent__id', 'name']
         unique_together = ['device', 'parent', 'name']
         unique_together = ['device', 'parent', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         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)):
     def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -75,7 +75,7 @@ class SiteTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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)):
     def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(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(
             self.assertEqual(
                 sorted(i.keys()),
                 sorted(i.keys()),
                 sorted(self.rack_fields),
                 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)):
     def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(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(
             self.assertEqual(
                 sorted(i.keys()),
                 sorted(i.keys()),
                 sorted(self.graph_fields),
                 sorted(self.graph_fields),
@@ -159,7 +159,7 @@ class RackTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -173,7 +173,7 @@ class RackTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             sorted(content.keys()),
@@ -202,7 +202,7 @@ class ManufacturersTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -212,7 +212,7 @@ class ManufacturersTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             sorted(content.keys()),
@@ -239,6 +239,7 @@ class DeviceTypeTest(APITestCase):
         'subdevice_role',
         'subdevice_role',
         'comments',
         'comments',
         'custom_fields',
         'custom_fields',
+        'instance_count',
     ]
     ]
 
 
     nested_fields = [
     nested_fields = [
@@ -250,7 +251,7 @@ class DeviceTypeTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -261,7 +262,7 @@ class DeviceTypeTest(APITestCase):
     def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
     def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
         # TODO: details returns list view.
         # TODO: details returns list view.
         # response = self.client.get(endpoint)
         # 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(response.status_code, status.HTTP_200_OK)
         # self.assertEqual(
         # self.assertEqual(
         #     sorted(content.keys()),
         #     sorted(content.keys()),
@@ -284,7 +285,7 @@ class DeviceRolesTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -294,7 +295,7 @@ class DeviceRolesTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             sorted(content.keys()),
@@ -312,7 +313,7 @@ class PlatformsTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -322,7 +323,7 @@ class PlatformsTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             sorted(content.keys()),
@@ -360,7 +361,7 @@ class DeviceTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for device in content:
         for device in content:
             self.assertEqual(
             self.assertEqual(
@@ -425,7 +426,7 @@ class DeviceTest(APITestCase):
         ]
         ]
 
 
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         device = content[0]
         device = content[0]
         self.assertEqual(
         self.assertEqual(
@@ -435,7 +436,7 @@ class DeviceTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for console_port in content:
         for console_port in content:
             self.assertEqual(
             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)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for console_port in content:
         for console_port in content:
             self.assertEqual(
             self.assertEqual(
@@ -493,7 +494,7 @@ class ConsolePortsTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -528,7 +529,7 @@ class PowerPortsTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -599,7 +600,7 @@ class InterfaceTest(APITestCase):
 
 
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
     def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         for i in content:
         for i in content:
             self.assertEqual(
             self.assertEqual(
@@ -613,7 +614,7 @@ class InterfaceTest(APITestCase):
 
 
     def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
     def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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)):
     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/'
     def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
                                        .format(settings.BASE_PATH)):
                                        .format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             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'
     def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
                                       .format(settings.BASE_PATH))):
                                       .format(settings.BASE_PATH))):
         response = self.client.get(endpoint)
         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(response.status_code, status.HTTP_200_OK)
         self.assertEqual(
         self.assertEqual(
             sorted(content.keys()),
             sorted(content.keys()),

+ 75 - 66
netbox/dcim/views.py

@@ -71,7 +71,7 @@ class ComponentCreateView(View):
             'parent': parent,
             'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': self.form(initial=request.GET),
             'form': self.form(initial=request.GET),
-            'cancel_url': parent.get_absolute_url(),
+            'return_url': parent.get_absolute_url(),
         })
         })
 
 
     def post(self, request, pk):
     def post(self, request, pk):
@@ -112,10 +112,22 @@ class ComponentCreateView(View):
             'parent': parent,
             'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': form,
             '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
 # Sites
 #
 #
@@ -125,7 +137,6 @@ class SiteListView(ObjectListView):
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
-    edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
     template_name = 'dcim/site_list.html'
     template_name = 'dcim/site_list.html'
 
 
 
 
@@ -157,7 +168,7 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
     model = Site
     model = Site
     form_class = forms.SiteForm
     form_class = forms.SiteForm
     template_name = 'dcim/site_edit.html'
     template_name = 'dcim/site_edit.html'
-    obj_list_url = 'dcim:site_list'
+    default_return_url = 'dcim:site_list'
 
 
 
 
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -171,15 +182,16 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.SiteImportForm
     form = forms.SiteImportForm
     table = tables.SiteTable
     table = tables.SiteTable
     template_name = 'dcim/site_import.html'
     template_name = 'dcim/site_import.html'
-    obj_list_url = 'dcim:site_list'
+    default_return_url = 'dcim:site_list'
 
 
 
 
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     permission_required = 'dcim.change_site'
     cls = Site
     cls = Site
+    filter = filters.SiteFilter
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm
     template_name = 'dcim/site_bulk_edit.html'
     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 = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     filter_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
     table = tables.RackGroupTable
-    edit_permissions = ['dcim.change_rackgroup', 'dcim.delete_rackgroup']
     template_name = 'dcim/rackgroup_list.html'
     template_name = 'dcim/rackgroup_list.html'
 
 
 
 
@@ -207,7 +218,8 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     permission_required = 'dcim.delete_rackgroup'
     cls = 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):
 class RackRoleListView(ObjectListView):
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
     table = tables.RackRoleTable
-    edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
     template_name = 'dcim/rackrole_list.html'
     template_name = 'dcim/rackrole_list.html'
 
 
 
 
@@ -233,7 +244,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackrole'
     permission_required = 'dcim.delete_rackrole'
     cls = 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 = filters.RackFilter
     filter_form = forms.RackFilterForm
     filter_form = forms.RackFilterForm
     table = tables.RackTable
     table = tables.RackTable
-    edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
     template_name = 'dcim/rack_list.html'
     template_name = 'dcim/rack_list.html'
 
 
 
 
@@ -274,7 +284,7 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
     model = Rack
     model = Rack
     form_class = forms.RackForm
     form_class = forms.RackForm
     template_name = 'dcim/rack_edit.html'
     template_name = 'dcim/rack_edit.html'
-    obj_list_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 
 
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -288,21 +298,23 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.RackImportForm
     form = forms.RackImportForm
     table = tables.RackImportTable
     table = tables.RackImportTable
     template_name = 'dcim/rack_import.html'
     template_name = 'dcim/rack_import.html'
-    obj_list_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 
 
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rack'
     permission_required = 'dcim.change_rack'
     cls = Rack
     cls = Rack
+    filter = filters.RackFilter
     form = forms.RackBulkEditForm
     form = forms.RackBulkEditForm
     template_name = 'dcim/rack_bulk_edit.html'
     template_name = 'dcim/rack_bulk_edit.html'
-    default_redirect_url = 'dcim:rack_list'
+    default_return_url = 'dcim:rack_list'
 
 
 
 
 class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rack'
     permission_required = 'dcim.delete_rack'
     cls = 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):
 class ManufacturerListView(ObjectListView):
     queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
     queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
-    edit_permissions = ['dcim.change_manufacturer', 'dcim.delete_manufacturer']
     template_name = 'dcim/manufacturer_list.html'
     template_name = 'dcim/manufacturer_list.html'
 
 
 
 
@@ -328,7 +339,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_manufacturer'
     permission_required = 'dcim.delete_manufacturer'
     cls = 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 = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     filter_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
-    edit_permissions = ['dcim.change_devicetype', 'dcim.delete_devicetype']
     template_name = 'dcim/devicetype_list.html'
     template_name = 'dcim/devicetype_list.html'
 
 
 
 
@@ -398,7 +408,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
     model = DeviceType
     model = DeviceType
     form_class = forms.DeviceTypeForm
     form_class = forms.DeviceTypeForm
     template_name = 'dcim/devicetype_edit.html'
     template_name = 'dcim/devicetype_edit.html'
-    obj_list_url = 'dcim:devicetype_list'
+    default_return_url = 'dcim:devicetype_list'
 
 
 
 
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -410,15 +420,17 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_devicetype'
     permission_required = 'dcim.change_devicetype'
     cls = DeviceType
     cls = DeviceType
+    filter = filters.DeviceTypeFilter
     form = forms.DeviceTypeBulkEditForm
     form = forms.DeviceTypeBulkEditForm
     template_name = 'dcim/devicetype_bulk_edit.html'
     template_name = 'dcim/devicetype_bulk_edit.html'
-    default_redirect_url = 'dcim:devicetype_list'
+    default_return_url = 'dcim:devicetype_list'
 
 
 
 
 class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicetype'
     permission_required = 'dcim.delete_devicetype'
     cls = 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):
 class DeviceRoleListView(ObjectListView):
     queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
     queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
-    edit_permissions = ['dcim.change_devicerole', 'dcim.delete_devicerole']
     template_name = 'dcim/devicerole_list.html'
     template_name = 'dcim/devicerole_list.html'
 
 
 
 
@@ -548,7 +559,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicerole'
     permission_required = 'dcim.delete_devicerole'
     cls = 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):
 class PlatformListView(ObjectListView):
     queryset = Platform.objects.annotate(device_count=Count('devices'))
     queryset = Platform.objects.annotate(device_count=Count('devices'))
     table = tables.PlatformTable
     table = tables.PlatformTable
-    edit_permissions = ['dcim.change_platform', 'dcim.delete_platform']
     template_name = 'dcim/platform_list.html'
     template_name = 'dcim/platform_list.html'
 
 
 
 
@@ -574,7 +584,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_platform'
     permission_required = 'dcim.delete_platform'
     cls = 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 = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
     filter_form = forms.DeviceFilterForm
     table = tables.DeviceTable
     table = tables.DeviceTable
-    edit_permissions = ['dcim.change_device', 'dcim.delete_device']
     template_name = 'dcim/device_list.html'
     template_name = 'dcim/device_list.html'
 
 
 
 
@@ -666,7 +675,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.DeviceForm
     form_class = forms.DeviceForm
     fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
     template_name = 'dcim/device_edit.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 
 
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -680,7 +689,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.DeviceImportForm
     form = forms.DeviceImportForm
     table = tables.DeviceImportTable
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import.html'
     template_name = 'dcim/device_import.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 
 
 class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -688,7 +697,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.ChildDeviceImportForm
     form = forms.ChildDeviceImportForm
     table = tables.DeviceImportTable
     table = tables.DeviceImportTable
     template_name = 'dcim/device_import_child.html'
     template_name = 'dcim/device_import_child.html'
-    obj_list_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
     def save_obj(self, obj):
     def save_obj(self, obj):
         # Inherent rack from parent device
         # Inherent rack from parent device
@@ -703,15 +712,17 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
     cls = Device
     cls = Device
+    filter = filters.DeviceFilter
     form = forms.DeviceBulkEditForm
     form = forms.DeviceBulkEditForm
     template_name = 'dcim/device_bulk_edit.html'
     template_name = 'dcim/device_bulk_edit.html'
-    default_redirect_url = 'dcim:device_list'
+    default_return_url = 'dcim:device_list'
 
 
 
 
 class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_device'
     permission_required = 'dcim.delete_device'
     cls = Device
     cls = Device
-    default_redirect_url = 'dcim:device_list'
+    filter = filters.DeviceFilter
+    default_return_url = 'dcim:device_list'
 
 
 
 
 def device_inventory(request, pk):
 def device_inventory(request, pk):
@@ -729,7 +740,8 @@ def device_inventory(request, pk):
 def device_lldp_neighbors(request, pk):
 def device_lldp_neighbors(request, pk):
 
 
     device = get_object_or_404(Device, pk=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', {
     return render(request, 'dcim/device_lldp_neighbors.html', {
         'device': device,
         'device': device,
@@ -776,7 +788,7 @@ def consoleport_connect(request, pk):
     return render(request, 'dcim/consoleport_connect.html', {
     return render(request, 'dcim/consoleport_connect.html', {
         'consoleport': consoleport,
         'consoleport': consoleport,
         'form': form,
         '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', {
     return render(request, 'dcim/consoleport_disconnect.html', {
         'consoleport': consoleport,
         'consoleport': consoleport,
         'form': form,
         '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'
     permission_required = 'dcim.change_consoleport'
     model = ConsolePort
     model = ConsolePort
     form_class = forms.ConsolePortForm
     form_class = forms.ConsolePortForm
 
 
 
 
-class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     model = ConsolePort
     model = ConsolePort
 
 
@@ -872,7 +884,7 @@ def consoleserverport_connect(request, pk):
     return render(request, 'dcim/consoleserverport_connect.html', {
     return render(request, 'dcim/consoleserverport_connect.html', {
         'consoleserverport': consoleserverport,
         'consoleserverport': consoleserverport,
         'form': form,
         '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', {
     return render(request, 'dcim/consoleserverport_disconnect.html', {
         'consoleserverport': consoleserverport,
         'consoleserverport': consoleserverport,
         'form': form,
         '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'
     permission_required = 'dcim.change_consoleserverport'
     model = ConsoleServerPort
     model = ConsoleServerPort
     form_class = forms.ConsoleServerPortForm
     form_class = forms.ConsoleServerPortForm
 
 
 
 
-class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     model = ConsoleServerPort
     model = ConsoleServerPort
 
 
@@ -962,7 +974,7 @@ def powerport_connect(request, pk):
     return render(request, 'dcim/powerport_connect.html', {
     return render(request, 'dcim/powerport_connect.html', {
         'powerport': powerport,
         'powerport': powerport,
         'form': form,
         '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', {
     return render(request, 'dcim/powerport_disconnect.html', {
         'powerport': powerport,
         'powerport': powerport,
         'form': form,
         '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'
     permission_required = 'dcim.change_powerport'
     model = PowerPort
     model = PowerPort
     form_class = forms.PowerPortForm
     form_class = forms.PowerPortForm
 
 
 
 
-class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     model = PowerPort
     model = PowerPort
 
 
@@ -1058,7 +1070,7 @@ def poweroutlet_connect(request, pk):
     return render(request, 'dcim/poweroutlet_connect.html', {
     return render(request, 'dcim/poweroutlet_connect.html', {
         'poweroutlet': poweroutlet,
         'poweroutlet': poweroutlet,
         'form': form,
         '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', {
     return render(request, 'dcim/poweroutlet_disconnect.html', {
         'poweroutlet': poweroutlet,
         'poweroutlet': poweroutlet,
         'form': form,
         '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'
     permission_required = 'dcim.change_poweroutlet'
     model = PowerOutlet
     model = PowerOutlet
     form_class = forms.PowerOutletForm
     form_class = forms.PowerOutletForm
 
 
 
 
-class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     model = PowerOutlet
     model = PowerOutlet
 
 
@@ -1121,13 +1133,13 @@ class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
 
 
 
 
-class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
+class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     model = Interface
     model = Interface
     form_class = forms.InterfaceForm
     form_class = forms.InterfaceForm
 
 
 
 
-class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     model = Interface
     model = Interface
 
 
@@ -1159,13 +1171,13 @@ class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
 
 
 
 
-class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
+class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_devicebay'
     permission_required = 'dcim.change_devicebay'
     model = DeviceBay
     model = DeviceBay
     form_class = forms.DeviceBayForm
     form_class = forms.DeviceBayForm
 
 
 
 
-class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     model = DeviceBay
     model = DeviceBay
 
 
@@ -1192,7 +1204,7 @@ def devicebay_populate(request, pk):
     return render(request, 'dcim/devicebay_populate.html', {
     return render(request, 'dcim/devicebay_populate.html', {
         'device_bay': device_bay,
         'device_bay': device_bay,
         'form': form,
         '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', {
     return render(request, 'dcim/devicebay_depopulate.html', {
         'device_bay': device_bay,
         'device_bay': device_bay,
         'form': form,
         '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?
         # Are we editing *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
         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:
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 
 
@@ -1291,7 +1303,7 @@ class DeviceBulkAddComponentView(View):
             'form': form,
             'form': form,
             'component_name': self.model._meta.verbose_name_plural,
             'component_name': self.model._meta.verbose_name_plural,
             'selected_devices': selected_devices,
             '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', {
     return render(request, 'dcim/interfaceconnection_edit.html', {
         'device': device,
         'device': device,
         'form': form,
         '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
     # Determine where to direct user upon cancellation
     if device_id:
     if device_id:
-        cancel_url = reverse('dcim:device', kwargs={'pk': device_id})
+        return_url = reverse('dcim:device', kwargs={'pk': device_id})
     else:
     else:
-        cancel_url = reverse('dcim:device_list')
+        return_url = reverse('dcim:device_list')
 
 
     return render(request, 'dcim/interfaceconnection_delete.html', {
     return render(request, 'dcim/interfaceconnection_delete.html', {
         'interfaceconnection': interfaceconnection,
         'interfaceconnection': interfaceconnection,
         'device_id': device_id,
         'device_id': device_id,
         'form': form,
         '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', {
     return render(request, 'dcim/ipaddress_assign.html', {
         'device': device,
         'device': device,
         'form': form,
         '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
 # Modules
 #
 #
 
 
-class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
+class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_module'
     permission_required = 'dcim.change_module'
     model = Module
     model = Module
     form_class = forms.ModuleForm
     form_class = forms.ModuleForm
@@ -1510,10 +1522,7 @@ class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
             obj.device = get_object_or_404(Device, pk=kwargs['device'])
             obj.device = get_object_or_404(Device, pk=kwargs['device'])
         return obj
         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'
     permission_required = 'dcim.delete_module'
     model = 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 render(self, data, media_type=None, renderer_context=None):
 
 
         def flatten(entry):
         def flatten(entry):
-            for key, val in entry.iteritems():
+            for key, val in entry.items():
                 if isinstance(val, dict):
                 if isinstance(val, dict):
                     for child_key, child_val in flatten(val):
                     for child_key, child_val in flatten(val):
                         yield "{}_{}".format(key, child_key), child_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.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.template import Template, Context
+from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 
 
@@ -93,6 +94,7 @@ class CustomFieldModel(object):
             return OrderedDict([(field, None) for field in fields])
             return OrderedDict([(field, None) for field in fields])
 
 
 
 
+@python_2_unicode_compatible
 class CustomField(models.Model):
 class CustomField(models.Model):
     obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
     obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
                                       limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
                                       limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -114,7 +116,7 @@ class CustomField(models.Model):
     class Meta:
     class Meta:
         ordering = ['weight', 'name']
         ordering = ['weight', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.label or self.name.replace('_', ' ').capitalize()
         return self.label or self.name.replace('_', ' ').capitalize()
 
 
     def serialize_value(self, value):
     def serialize_value(self, value):
@@ -153,6 +155,7 @@ class CustomField(models.Model):
         return serialized_value
         return serialized_value
 
 
 
 
+@python_2_unicode_compatible
 class CustomFieldValue(models.Model):
 class CustomFieldValue(models.Model):
     field = models.ForeignKey('CustomField', related_name='values')
     field = models.ForeignKey('CustomField', related_name='values')
     obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
     obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
@@ -164,7 +167,7 @@ class CustomFieldValue(models.Model):
         ordering = ['obj_type', 'obj_id']
         ordering = ['obj_type', 'obj_id']
         unique_together = ['field', 'obj_type', 'obj_id']
         unique_together = ['field', 'obj_type', 'obj_id']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} {}'.format(self.obj, self.field)
         return u'{} {}'.format(self.obj, self.field)
 
 
     @property
     @property
@@ -183,6 +186,7 @@ class CustomFieldValue(models.Model):
             super(CustomFieldValue, self).save(*args, **kwargs)
             super(CustomFieldValue, self).save(*args, **kwargs)
 
 
 
 
+@python_2_unicode_compatible
 class CustomFieldChoice(models.Model):
 class CustomFieldChoice(models.Model):
     field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
     field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
                               on_delete=models.CASCADE)
                               on_delete=models.CASCADE)
@@ -193,7 +197,7 @@ class CustomFieldChoice(models.Model):
         ordering = ['field', 'weight', 'value']
         ordering = ['field', 'weight', 'value']
         unique_together = ['field', 'value']
         unique_together = ['field', 'value']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.value
         return self.value
 
 
     def clean(self):
     def clean(self):
@@ -207,6 +211,7 @@ class CustomFieldChoice(models.Model):
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
 
 
 
 
+@python_2_unicode_compatible
 class Graph(models.Model):
 class Graph(models.Model):
     type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
     type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
     weight = models.PositiveSmallIntegerField(default=1000)
     weight = models.PositiveSmallIntegerField(default=1000)
@@ -217,7 +222,7 @@ class Graph(models.Model):
     class Meta:
     class Meta:
         ordering = ['type', 'weight', 'name']
         ordering = ['type', 'weight', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def embed_url(self, obj):
     def embed_url(self, obj):
@@ -231,6 +236,7 @@ class Graph(models.Model):
         return template.render(Context({'obj': obj}))
         return template.render(Context({'obj': obj}))
 
 
 
 
+@python_2_unicode_compatible
 class ExportTemplate(models.Model):
 class ExportTemplate(models.Model):
     content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
     content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
     name = models.CharField(max_length=100)
     name = models.CharField(max_length=100)
@@ -245,7 +251,7 @@ class ExportTemplate(models.Model):
             ['content_type', 'name']
             ['content_type', 'name']
         ]
         ]
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{}: {}'.format(self.content_type, self.name)
         return u'{}: {}'.format(self.content_type, self.name)
 
 
     def to_response(self, context_dict, filename):
     def to_response(self, context_dict, filename):
@@ -264,6 +270,7 @@ class ExportTemplate(models.Model):
         return response
         return response
 
 
 
 
+@python_2_unicode_compatible
 class TopologyMap(models.Model):
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
@@ -278,7 +285,7 @@ class TopologyMap(models.Model):
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     @property
     @property
@@ -328,6 +335,7 @@ class UserActionManager(models.Manager):
         self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
         self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
 
 
 
 
+@python_2_unicode_compatible
 class UserAction(models.Model):
 class UserAction(models.Model):
     """
     """
     A record of an action (add, edit, or delete) performed on an object by a User.
     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:
     class Meta:
         ordering = ['-time']
         ordering = ['-time']
 
 
-    def __unicode__(self):
+    def __str__(self):
         if self.message:
         if self.message:
             return u'{} {}'.format(self.user, self.message)
             return u'{} {}'.format(self.user, self.message)
         return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
         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.
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 import os
 import os
 import random
 import random
 
 
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
 random.seed = (os.urandom(2048))
 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):
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
     model = VRF
+    q = forms.CharField(required=False, label='Search')
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
                                null_option=(0, None))
                                null_option=(0, None))
 
 
@@ -128,6 +129,7 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Aggregate
     model = Aggregate
+    q = forms.CharField(required=False, label='Search')
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     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',
     rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
                             label='RIR')
                             label='RIR')
@@ -256,8 +258,9 @@ def prefix_status_choices():
 
 
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Prefix
     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')
     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',
     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):
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
     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',
         'placeholder': 'Prefix',
     }))
     }))
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
     family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
@@ -560,6 +564,7 @@ def vlan_status_choices():
 
 
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VLAN
     model = VLAN
+    q = forms.CharField(required=False, label='Search')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
     group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
     group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
                                  null_option=(0, 'None'))
                                  null_option=(0, 'None'))

+ 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.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
+from django.utils.encoding import python_2_unicode_compatible
 
 
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
@@ -36,10 +37,12 @@ PREFIX_STATUS_CHOICES = (
 
 
 IPADDRESS_STATUS_ACTIVE = 1
 IPADDRESS_STATUS_ACTIVE = 1
 IPADDRESS_STATUS_RESERVED = 2
 IPADDRESS_STATUS_RESERVED = 2
+IPADDRESS_STATUS_DEPRECATED = 3
 IPADDRESS_STATUS_DHCP = 5
 IPADDRESS_STATUS_DHCP = 5
 IPADDRESS_STATUS_CHOICES = (
 IPADDRESS_STATUS_CHOICES = (
     (IPADDRESS_STATUS_ACTIVE, 'Active'),
     (IPADDRESS_STATUS_ACTIVE, 'Active'),
     (IPADDRESS_STATUS_RESERVED, 'Reserved'),
     (IPADDRESS_STATUS_RESERVED, 'Reserved'),
+    (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
     (IPADDRESS_STATUS_DHCP, 'DHCP')
     (IPADDRESS_STATUS_DHCP, 'DHCP')
 )
 )
 
 
@@ -70,6 +73,7 @@ IP_PROTOCOL_CHOICES = (
 )
 )
 
 
 
 
+@python_2_unicode_compatible
 class VRF(CreatedUpdatedModel, CustomFieldModel):
 class VRF(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
     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 = 'VRF'
         verbose_name_plural = 'VRFs'
         verbose_name_plural = 'VRFs'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -105,6 +109,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         ])
         ])
 
 
 
 
+@python_2_unicode_compatible
 class RIR(models.Model):
 class RIR(models.Model):
     """
     """
     A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
     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 = 'RIR'
         verbose_name_plural = 'RIRs'
         verbose_name_plural = 'RIRs'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
 
 
 
 
+@python_2_unicode_compatible
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
     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:
     class Meta:
         ordering = ['family', 'prefix']
         ordering = ['family', 'prefix']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.prefix)
         return str(self.prefix)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -204,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         return int(children_size / self.prefix.size * 100)
         return int(children_size / self.prefix.size * 100)
 
 
 
 
+@python_2_unicode_compatible
 class Role(models.Model):
 class Role(models.Model):
     """
     """
     A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
     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:
     class Meta:
         ordering = ['weight', 'name']
         ordering = ['weight', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     @property
     @property
@@ -263,6 +270,7 @@ class PrefixQuerySet(NullsFirstQuerySet):
         return filter(lambda p: p.depth <= limit, queryset)
         return filter(lambda p: p.depth <= limit, queryset)
 
 
 
 
+@python_2_unicode_compatible
 class Prefix(CreatedUpdatedModel, CustomFieldModel):
 class Prefix(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
     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']
         ordering = ['vrf', 'family', 'prefix']
         verbose_name_plural = 'prefixes'
         verbose_name_plural = 'prefixes'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.prefix)
         return str(self.prefix)
 
 
     def get_absolute_url(self):
     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')
         return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
 
 
 
 
+@python_2_unicode_compatible
 class IPAddress(CreatedUpdatedModel, CustomFieldModel):
 class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
     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 = 'IP address'
         verbose_name_plural = 'IP addresses'
         verbose_name_plural = 'IP addresses'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return str(self.address)
         return str(self.address)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -469,6 +478,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
         return STATUS_CHOICE_CLASSES[self.status]
         return STATUS_CHOICE_CLASSES[self.status]
 
 
 
 
+@python_2_unicode_compatible
 class VLANGroup(models.Model):
 class VLANGroup(models.Model):
     """
     """
     A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
     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 = 'VLAN group'
         verbose_name_plural = 'VLAN groups'
         verbose_name_plural = 'VLAN groups'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return u'{} - {}'.format(self.site.name, self.name)
         return u'{} - {}'.format(self.site.name, self.name)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
 
 
 
 
+@python_2_unicode_compatible
 class VLAN(CreatedUpdatedModel, CustomFieldModel):
 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
     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 = 'VLAN'
         verbose_name_plural = 'VLANs'
         verbose_name_plural = 'VLANs'
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.display_name
         return self.display_name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
@@ -558,6 +569,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
         return STATUS_CHOICE_CLASSES[self.status]
         return STATUS_CHOICE_CLASSES[self.status]
 
 
 
 
+@python_2_unicode_compatible
 class Service(CreatedUpdatedModel):
 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
     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']
         ordering = ['device', 'protocol', 'port']
         unique_together = ['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())
         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')
     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')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     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')
     role = tables.Column(verbose_name='Role')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
-        fields = ('prefix', 'vrf', 'status', 'site', 'role')
+        fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
         orderable = False
         orderable = False
 
 
 
 

+ 43 - 35
netbox/ipam/views.py

@@ -95,7 +95,6 @@ class VRFListView(ObjectListView):
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
     filter_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
-    edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
     template_name = 'ipam/vrf_list.html'
     template_name = 'ipam/vrf_list.html'
 
 
 
 
@@ -118,7 +117,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
     model = VRF
     model = VRF
     form_class = forms.VRFForm
     form_class = forms.VRFForm
     template_name = 'ipam/vrf_edit.html'
     template_name = 'ipam/vrf_edit.html'
-    obj_list_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 
 
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -132,21 +131,23 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.VRFImportForm
     form = forms.VRFImportForm
     table = tables.VRFTable
     table = tables.VRFTable
     template_name = 'ipam/vrf_import.html'
     template_name = 'ipam/vrf_import.html'
-    obj_list_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 
 
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vrf'
     permission_required = 'ipam.change_vrf'
     cls = VRF
     cls = VRF
+    filter = filters.VRFFilter
     form = forms.VRFBulkEditForm
     form = forms.VRFBulkEditForm
     template_name = 'ipam/vrf_bulk_edit.html'
     template_name = 'ipam/vrf_bulk_edit.html'
-    default_redirect_url = 'ipam:vrf_list'
+    default_return_url = 'ipam:vrf_list'
 
 
 
 
 class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vrf'
     permission_required = 'ipam.delete_vrf'
     cls = 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 = filters.RIRFilter
     filter_form = forms.RIRFilterForm
     filter_form = forms.RIRFilterForm
     table = tables.RIRTable
     table = tables.RIRTable
-    edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
     template_name = 'ipam/rir_list.html'
     template_name = 'ipam/rir_list.html'
 
 
     def alter_queryset(self, request):
     def alter_queryset(self, request):
@@ -250,7 +250,8 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_rir'
     permission_required = 'ipam.delete_rir'
     cls = 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 = filters.AggregateFilter
     filter_form = forms.AggregateFilterForm
     filter_form = forms.AggregateFilterForm
     table = tables.AggregateTable
     table = tables.AggregateTable
-    edit_permissions = ['ipam.change_aggregate', 'ipam.delete_aggregate']
     template_name = 'ipam/aggregate_list.html'
     template_name = 'ipam/aggregate_list.html'
 
 
     def extra_context(self):
     def extra_context(self):
@@ -308,7 +308,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
     model = Aggregate
     model = Aggregate
     form_class = forms.AggregateForm
     form_class = forms.AggregateForm
     template_name = 'ipam/aggregate_edit.html'
     template_name = 'ipam/aggregate_edit.html'
-    obj_list_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 
 
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -322,21 +322,23 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.AggregateImportForm
     form = forms.AggregateImportForm
     table = tables.AggregateTable
     table = tables.AggregateTable
     template_name = 'ipam/aggregate_import.html'
     template_name = 'ipam/aggregate_import.html'
-    obj_list_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 
 
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_aggregate'
     permission_required = 'ipam.change_aggregate'
     cls = Aggregate
     cls = Aggregate
+    filter = filters.AggregateFilter
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
     template_name = 'ipam/aggregate_bulk_edit.html'
     template_name = 'ipam/aggregate_bulk_edit.html'
-    default_redirect_url = 'ipam:aggregate_list'
+    default_return_url = 'ipam:aggregate_list'
 
 
 
 
 class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_aggregate'
     permission_required = 'ipam.delete_aggregate'
     cls = 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):
 class RoleListView(ObjectListView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     table = tables.RoleTable
     table = tables.RoleTable
-    edit_permissions = ['ipam.change_role', 'ipam.delete_role']
     template_name = 'ipam/role_list.html'
     template_name = 'ipam/role_list.html'
 
 
 
 
@@ -362,7 +363,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_role'
     permission_required = 'ipam.delete_role'
     cls = 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 = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
     table = tables.PrefixTable
     table = tables.PrefixTable
-    edit_permissions = ['ipam.change_prefix', 'ipam.delete_prefix']
     template_name = 'ipam/prefix_list.html'
     template_name = 'ipam/prefix_list.html'
 
 
     def alter_queryset(self, request):
     def alter_queryset(self, request):
@@ -401,11 +401,13 @@ def prefix(request, pk):
         .filter(prefix__net_contains=str(prefix.prefix))\
         .filter(prefix__net_contains=str(prefix.prefix))\
         .select_related('site', 'role').annotate_depth()
         .select_related('site', 'role').annotate_depth()
     parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
     parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
+    parent_prefix_table.exclude = ('vrf',)
 
 
     # Duplicate prefixes table
     # Duplicate prefixes table
     duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
     duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
         .select_related('site', 'role')
         .select_related('site', 'role')
     duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
     duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
+    duplicate_prefix_table.exclude = ('vrf',)
 
 
     # Child prefixes table
     # Child prefixes table
     if prefix.vrf:
     if prefix.vrf:
@@ -430,6 +432,7 @@ def prefix(request, pk):
         'parent_prefix_table': parent_prefix_table,
         'parent_prefix_table': parent_prefix_table,
         'child_prefix_table': child_prefix_table,
         'child_prefix_table': child_prefix_table,
         'duplicate_prefix_table': duplicate_prefix_table,
         'duplicate_prefix_table': duplicate_prefix_table,
+        'return_url': prefix.get_absolute_url(),
     })
     })
 
 
 
 
@@ -439,14 +442,14 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.PrefixForm
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
     template_name = 'ipam/prefix_edit.html'
     fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
     fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
-    obj_list_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 
 
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_prefix'
     permission_required = 'ipam.delete_prefix'
     model = Prefix
     model = Prefix
-    default_return_url = 'ipam:prefix_list'
     template_name = 'ipam/prefix_delete.html'
     template_name = 'ipam/prefix_delete.html'
+    default_return_url = 'ipam:prefix_list'
 
 
 
 
 class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -454,21 +457,23 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.PrefixImportForm
     form = forms.PrefixImportForm
     table = tables.PrefixTable
     table = tables.PrefixTable
     template_name = 'ipam/prefix_import.html'
     template_name = 'ipam/prefix_import.html'
-    obj_list_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 
 
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_prefix'
     permission_required = 'ipam.change_prefix'
     cls = Prefix
     cls = Prefix
+    filter = filters.PrefixFilter
     form = forms.PrefixBulkEditForm
     form = forms.PrefixBulkEditForm
     template_name = 'ipam/prefix_bulk_edit.html'
     template_name = 'ipam/prefix_bulk_edit.html'
-    default_redirect_url = 'ipam:prefix_list'
+    default_return_url = 'ipam:prefix_list'
 
 
 
 
 class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_prefix'
     permission_required = 'ipam.delete_prefix'
     cls = Prefix
     cls = Prefix
-    default_redirect_url = 'ipam:prefix_list'
+    filter = filters.PrefixFilter
+    default_return_url = 'ipam:prefix_list'
 
 
 
 
 def prefix_ipaddresses(request, pk):
 def prefix_ipaddresses(request, pk):
@@ -500,7 +505,6 @@ class IPAddressListView(ObjectListView):
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressTable
     table = tables.IPAddressTable
-    edit_permissions = ['ipam.change_ipaddress', 'ipam.delete_ipaddress']
     template_name = 'ipam/ipaddress_list.html'
     template_name = 'ipam/ipaddress_list.html'
 
 
 
 
@@ -562,7 +566,7 @@ def ipaddress_assign(request, pk):
     return render(request, 'ipam/ipaddress_assign.html', {
     return render(request, 'ipam/ipaddress_assign.html', {
         'ipaddress': ipaddress,
         'ipaddress': ipaddress,
         'form': form,
         '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', {
     return render(request, 'ipam/ipaddress_unassign.html', {
         'ipaddress': ipaddress,
         'ipaddress': ipaddress,
         'form': form,
         '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
     form_class = forms.IPAddressForm
     fields_initial = ['address', 'vrf']
     fields_initial = ['address', 'vrf']
     template_name = 'ipam/ipaddress_edit.html'
     template_name = 'ipam/ipaddress_edit.html'
-    obj_list_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 
 
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -619,7 +623,7 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
     form = forms.IPAddressBulkAddForm
     form = forms.IPAddressBulkAddForm
     model = IPAddress
     model = IPAddress
     template_name = 'ipam/ipaddress_bulk_add.html'
     template_name = 'ipam/ipaddress_bulk_add.html'
-    redirect_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 
 
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -627,7 +631,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.IPAddressImportForm
     form = forms.IPAddressImportForm
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     template_name = 'ipam/ipaddress_import.html'
     template_name = 'ipam/ipaddress_import.html'
-    obj_list_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
     def save_obj(self, obj):
     def save_obj(self, obj):
         obj.save()
         obj.save()
@@ -648,15 +652,17 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_ipaddress'
     permission_required = 'ipam.change_ipaddress'
     cls = IPAddress
     cls = IPAddress
+    filter = filters.IPAddressFilter
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
     template_name = 'ipam/ipaddress_bulk_edit.html'
     template_name = 'ipam/ipaddress_bulk_edit.html'
-    default_redirect_url = 'ipam:ipaddress_list'
+    default_return_url = 'ipam:ipaddress_list'
 
 
 
 
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     permission_required = 'ipam.delete_ipaddress'
     cls = 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 = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     filter_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
-    edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
     template_name = 'ipam/vlangroup_list.html'
     template_name = 'ipam/vlangroup_list.html'
 
 
 
 
@@ -684,7 +689,8 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     permission_required = 'ipam.delete_vlangroup'
     cls = 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 = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
     table = tables.VLANTable
     table = tables.VLANTable
-    edit_permissions = ['ipam.change_vlan', 'ipam.delete_vlan']
     template_name = 'ipam/vlan_list.html'
     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)
     vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
     prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
     prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
     prefix_table = tables.PrefixBriefTable(list(prefixes))
     prefix_table = tables.PrefixBriefTable(list(prefixes))
+    prefix_table.exclude = ('vlan',)
 
 
     return render(request, 'ipam/vlan.html', {
     return render(request, 'ipam/vlan.html', {
         'vlan': vlan,
         'vlan': vlan,
@@ -717,7 +723,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
     model = VLAN
     model = VLAN
     form_class = forms.VLANForm
     form_class = forms.VLANForm
     template_name = 'ipam/vlan_edit.html'
     template_name = 'ipam/vlan_edit.html'
-    obj_list_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 
 
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -731,21 +737,23 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.VLANImportForm
     form = forms.VLANImportForm
     table = tables.VLANTable
     table = tables.VLANTable
     template_name = 'ipam/vlan_import.html'
     template_name = 'ipam/vlan_import.html'
-    obj_list_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 
 
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vlan'
     permission_required = 'ipam.change_vlan'
     cls = VLAN
     cls = VLAN
+    filter = filters.VLANFilter
     form = forms.VLANBulkEditForm
     form = forms.VLANBulkEditForm
     template_name = 'ipam/vlan_bulk_edit.html'
     template_name = 'ipam/vlan_bulk_edit.html'
-    default_redirect_url = 'ipam:vlan_list'
+    default_return_url = 'ipam:vlan_list'
 
 
 
 
 class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlan'
     permission_required = 'ipam.delete_vlan'
     cls = 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
 from django.core.exceptions import ImproperlyConfigured
 
 
 try:
 try:
-    import configuration
+    from netbox import configuration
 except ImportError:
 except ImportError:
     raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
     raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.8.2'
+VERSION = '1.8.3'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 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
 # Attempt to import LDAP configuration if it has been defined
 LDAP_IGNORE_CERT_ERRORS = False
 LDAP_IGNORE_CERT_ERRORS = False
 try:
 try:
-    from ldap_config import *
+    from netbox.ldap_config import *
     LDAP_CONFIGURED = True
     LDAP_CONFIGURED = True
 except ImportError:
 except ImportError:
     LDAP_CONFIGURED = False
     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.conf.urls import include, url
 from django.contrib import admin
 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
 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);
             $('#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
     // Uncheck the "toggle all" checkbox if an item is unchecked
     $('input:checkbox[name=pk]').click(function (event) {
     $('input:checkbox[name=pk]').click(function (event) {
         if (!$(this).attr('checked')) {
         if (!$(this).attr('checked')) {

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

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

+ 1 - 0
netbox/secrets/forms.py

@@ -100,6 +100,7 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
 
 
 
 
 class SecretFilterForm(BootstrapMixin, forms.Form):
 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')
     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.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.db import models
 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 dcim.models import Device
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
@@ -51,6 +51,7 @@ class UserKeyQuerySet(models.QuerySet):
         raise Exception("Bulk deletion has been disabled.")
         raise Exception("Bulk deletion has been disabled.")
 
 
 
 
+@python_2_unicode_compatible
 class UserKey(CreatedUpdatedModel):
 class UserKey(CreatedUpdatedModel):
     """
     """
     A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
     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_public_key = self.public_key
         self.__initial_master_key_cipher = self.master_key_cipher
         self.__initial_master_key_cipher = self.master_key_cipher
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.user.username
         return self.user.username
 
 
     def clean(self, *args, **kwargs):
     def clean(self, *args, **kwargs):
@@ -170,6 +171,7 @@ class UserKey(CreatedUpdatedModel):
         self.save()
         self.save()
 
 
 
 
+@python_2_unicode_compatible
 class SecretRole(models.Model):
 class SecretRole(models.Model):
     """
     """
     A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     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()
         return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
 
 
 
 
+@python_2_unicode_compatible
 class Secret(CreatedUpdatedModel):
 class Secret(CreatedUpdatedModel):
     """
     """
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
     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)
         self.plaintext = kwargs.pop('plaintext', None)
         super(Secret, self).__init__(*args, **kwargs)
         super(Secret, self).__init__(*args, **kwargs)
 
 
-    def __unicode__(self):
+    def __str__(self):
         if self.role and self.device:
         if self.role and self.device:
             return u'{} for {}'.format(self.role, self.device)
             return u'{} for {}'.format(self.role, self.device)
         return u'Secret'
         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):
 class SecretRoleListView(ObjectListView):
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
     table = tables.SecretRoleTable
-    edit_permissions = ['secrets.change_secretrole', 'secrets.delete_secretrole']
     template_name = 'secrets/secretrole_list.html'
     template_name = 'secrets/secretrole_list.html'
 
 
 
 
@@ -38,7 +37,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secretrole'
     permission_required = 'secrets.delete_secretrole'
     cls = 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 = filters.SecretFilter
     filter_form = forms.SecretFilterForm
     filter_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
-    edit_permissions = ['secrets.change_secret', 'secrets.delete_secret']
     template_name = 'secrets/secret_list.html'
     template_name = 'secrets/secret_list.html'
 
 
 
 
@@ -103,7 +101,7 @@ def secret_add(request, pk):
     return render(request, 'secrets/secret_edit.html', {
     return render(request, 'secrets/secret_edit.html', {
         'secret': secret,
         'secret': secret,
         'form': form,
         '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', {
     return render(request, 'secrets/secret_edit.html', {
         'secret': secret,
         'secret': secret,
         'form': form,
         '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', {
     return render(request, 'secrets/secret_import.html', {
         'form': form,
         'form': form,
-        'cancel_url': reverse('secrets:secret_list'),
+        'return_url': reverse('secrets:secret_list'),
     })
     })
 
 
 
 
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'secrets.change_secret'
     permission_required = 'secrets.change_secret'
     cls = Secret
     cls = Secret
+    filter = filters.SecretFilter
     form = forms.SecretBulkEditForm
     form = forms.SecretBulkEditForm
     template_name = 'secrets/secret_bulk_edit.html'
     template_name = 'secrets/secret_bulk_edit.html'
-    default_redirect_url = 'secrets:secret_list'
+    default_return_url = 'secrets:secret_list'
 
 
 
 
 class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secret'
     permission_required = 'secrets.delete_secret'
     cls = 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>
 		</div>
 		</div>
 	</footer>
 	</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 'js/jquery-2.1.4.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.11.4/jquery-ui.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>
 <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 %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

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

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

@@ -40,7 +40,7 @@
             <div class="form-group">
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
                     <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>
             </div>
         </div>
         </div>

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

@@ -40,7 +40,7 @@
             <div class="form-group">
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
                     <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>
             </div>
         </div>
         </div>

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

@@ -550,7 +550,7 @@
 function toggleConnection(elem, api_url) {
 function toggleConnection(elem, api_url) {
     if (elem.hasClass('connected')) {
     if (elem.hasClass('connected')) {
         $.ajax({
         $.ajax({
-            url: api_url + elem.attr('data') + "/",
+            url: netbox_api_path + api_url + elem.attr('data') + "/",
             method: 'PATCH',
             method: 'PATCH',
             dataType: 'json',
             dataType: 'json',
             beforeSend: function(xhr, settings) {
             beforeSend: function(xhr, settings) {
@@ -590,13 +590,13 @@ function toggleConnection(elem, api_url) {
     return false;
     return false;
 }
 }
 $(".consoleport-toggle").click(function() {
 $(".consoleport-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
+    return toggleConnection($(this), "dcim/console-ports/");
 });
 });
 $(".powerport-toggle").click(function() {
 $(".powerport-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
+    return toggleConnection($(this), "dcim/power-ports/");
 });
 });
 $(".interface-toggle").click(function() {
 $(".interface-toggle").click(function() {
-    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
+    return toggleConnection($(this), "dcim/interface-connections/");
 });
 });
 </script>
 </script>
 <script src="{% static 'js/graphs.js' %}"></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>
 <h1>Add {{ component_name|title }}</h1>
 <form action="." method="post" class="form form-horizontal">
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     {% 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 %}
     {% endif %}
     {% for field in form.hidden_fields %}
     {% for field in form.hidden_fields %}
         {{ field }}
         {{ field }}
@@ -51,7 +51,7 @@
 		    <div class="form-group text-right">
 		    <div class="form-group text-right">
                 <div class="col-md-12">
                 <div class="col-md-12">
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <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>
 		    </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">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <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>
                     <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>
 		    </div>
         </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
             <div class="form-group">
             <div class="form-group">
                 <button type="submit" class="btn btn-primary">Submit</button>
                 <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>
             </div>
 		</form>
 		</form>
 		<h4>CSV Format</h4>
 		<h4>CSV Format</h4>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
             <div class="form-group">
             <div class="form-group">
                 <button type="submit" class="btn btn-primary">Submit</button>
                 <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>
             </div>
 		</form>
 		</form>
 		<h4>CSV Format</h4>
 		<h4>CSV Format</h4>

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

@@ -24,7 +24,31 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% 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="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Save</button>
                     <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>
 		    </div>
         </div>
         </div>

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

@@ -33,7 +33,7 @@
 		    <div class="form-group">
 		    <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Save</button>
                     <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>
 		    </div>
         </div>
         </div>

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

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

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

@@ -50,7 +50,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?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>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

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

@@ -49,7 +49,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?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>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
                 </a>
                 </a>
             {% endif %}
             {% 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>
                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             </button>
             <ul class="dropdown-menu">
             <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>
             </ul>
         </div>
         </div>
     {% endif %}
     {% endif %}

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

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

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

@@ -85,7 +85,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim: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>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

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

@@ -49,7 +49,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?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>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete outlet"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

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

@@ -50,7 +50,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?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>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

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

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

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

@@ -86,7 +86,7 @@
     <div class="form-group">
     <div class="form-group">
         <button type="submit" name="_create" class="btn btn-primary">Connect</button>
         <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>
         <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>
 </div>
 </div>
 </form>
 </form>

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

@@ -53,7 +53,7 @@
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <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>
                     <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>
 		    </div>
         </div>
         </div>

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

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

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

@@ -40,7 +40,7 @@
             <div class="form-group">
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
                     <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>
             </div>
         </div>
         </div>

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

@@ -40,7 +40,7 @@
             <div class="form-group">
             <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_update" class="btn btn-primary">Connect</button>
                     <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>
             </div>
         </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

@@ -23,7 +23,6 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/filter_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% 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 panel-default">
     <div class="panel-heading">
     <div class="panel-heading">
         <span class="fa fa-search" aria-hidden="true"></span>
         <span class="fa fa-search" aria-hidden="true"></span>
         <strong>Search</strong>
         <strong>Search</strong>
     </div>
     </div>
     <div class="panel-body">
     <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">
                     <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>
                     </button>
-                </span>
-            </div>
+                    <a href="." class="btn btn-default">
+                        <span class="fa fa-remove" aria-hidden="true"></span> Clear
+                    </a>
+                </div>
         </form>
         </form>
     </div>
     </div>
 </div>
 </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -59,7 +59,7 @@
 		    <div class="form-group">
 		    <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_assign" class="btn btn-primary">Assign</button>
                     <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>
 		    </div>
         </div>
         </div>

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -59,7 +59,7 @@
                 {% else %}
                 {% else %}
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <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>
                     <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 %}
                 {% endif %}
 		    </div>
 		    </div>
         </div>
         </div>

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

@@ -22,7 +22,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -13,7 +13,7 @@
 		    {% render_form form %}
 		    {% render_form form %}
 		    <div class="form-group">
 		    <div class="form-group">
 		        <button type="submit" class="btn btn-primary">Submit</button>
 		        <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>
 		    </div>
 		</form>
 		</form>
 	</div>
 	</div>

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

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

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

@@ -5,8 +5,8 @@
 <h1>{% block title %}{% endblock %}</h1>
 <h1>{% block title %}{% endblock %}</h1>
 <form action="." method="post" class="form form-horizontal">
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     {% 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 %}
     {% endif %}
     {% for field in form.hidden_fields %}
     {% for field in form.hidden_fields %}
         {{ field }}
         {{ field }}
@@ -44,7 +44,7 @@
 		    <div class="form-group text-right">
 		    <div class="form-group text-right">
                 <div class="col-md-12">
                 <div class="col-md-12">
                     <button type="submit" name="_apply" class="btn btn-primary">Apply</button>
                     <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>
 		    </div>
         </div>
         </div>

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

@@ -5,7 +5,7 @@
 
 
 {% block message %}
 {% block message %}
     <p>
     <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>
     </p>
     <ul>
     <ul>
         {% for obj in selected_objects %}
         {% for obj in selected_objects %}

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

@@ -23,7 +23,7 @@
                     </div>
                     </div>
                     <div class="text-right">
                     <div class="text-right">
                         <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
                         <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>
                 </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="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
                 {% endif %}
                 {% endif %}
-                <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
             </div>
             </div>
         </div>
         </div>
     </form>
     </form>

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

@@ -1,29 +1,42 @@
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
 {% load helpers %}
 {% 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">
     <form method="post" class="form form-horizontal">
         {% csrf_token %}
         {% 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 %}
         {% 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>
             </div>
             </div>
         {% endif %}
         {% endif %}
         {% render_table table table_template|default:'table.html' %}
         {% render_table table table_template|default:'table.html' %}
         {% block extra_actions %}{% endblock %}
         {% 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
                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
             </button>
             </button>
         {% endif %}
         {% 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
                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
             </button>
             </button>
         {% endif %}
         {% endif %}

+ 1 - 0
netbox/tenancy/forms.py

@@ -55,5 +55,6 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
 class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Tenant
     model = Tenant
+    q = forms.CharField(required=False, label='Search')
     group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
     group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
                               to_field_name='slug', null_option=(0, 'None'))
                               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.contrib.contenttypes.fields import GenericRelation
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.db import models
 from django.db import models
+from django.utils.encoding import python_2_unicode_compatible
 
 
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 from utilities.utils import csv_format
 from utilities.utils import csv_format
 
 
 
 
+@python_2_unicode_compatible
 class TenantGroup(models.Model):
 class TenantGroup(models.Model):
     """
     """
     An arbitrary collection of Tenants.
     An arbitrary collection of Tenants.
@@ -17,13 +19,14 @@ class TenantGroup(models.Model):
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
         return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
 
 
 
 
+@python_2_unicode_compatible
 class Tenant(CreatedUpdatedModel, CustomFieldModel):
 class Tenant(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
     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:
     class Meta:
         ordering = ['group', 'name']
         ordering = ['group', 'name']
 
 
-    def __unicode__(self):
+    def __str__(self):
         return self.name
         return self.name
 
 
     def get_absolute_url(self):
     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,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 
 
-from models import Tenant, TenantGroup
+from .models import Tenant, TenantGroup
 from . import filters, forms, tables
 from . import filters, forms, tables
 
 
 
 
@@ -21,7 +21,6 @@ from . import filters, forms, tables
 class TenantGroupListView(ObjectListView):
 class TenantGroupListView(ObjectListView):
     queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
     queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
     table = tables.TenantGroupTable
     table = tables.TenantGroupTable
-    edit_permissions = ['tenancy.change_tenantgroup', 'tenancy.delete_tenantgroup']
     template_name = 'tenancy/tenantgroup_list.html'
     template_name = 'tenancy/tenantgroup_list.html'
 
 
 
 
@@ -37,7 +36,7 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenantgroup'
     permission_required = 'tenancy.delete_tenantgroup'
     cls = 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 = filters.TenantFilter
     filter_form = forms.TenantFilterForm
     filter_form = forms.TenantFilterForm
     table = tables.TenantTable
     table = tables.TenantTable
-    edit_permissions = ['tenancy.change_tenant', 'tenancy.delete_tenant']
     template_name = 'tenancy/tenant_list.html'
     template_name = 'tenancy/tenant_list.html'
 
 
 
 
@@ -85,7 +83,7 @@ class TenantEditView(PermissionRequiredMixin, ObjectEditView):
     form_class = forms.TenantForm
     form_class = forms.TenantForm
     fields_initial = ['group']
     fields_initial = ['group']
     template_name = 'tenancy/tenant_edit.html'
     template_name = 'tenancy/tenant_edit.html'
-    obj_list_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 
 
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -99,18 +97,20 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
     form = forms.TenantImportForm
     form = forms.TenantImportForm
     table = tables.TenantTable
     table = tables.TenantTable
     template_name = 'tenancy/tenant_import.html'
     template_name = 'tenancy/tenant_import.html'
-    obj_list_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 
 
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'tenancy.change_tenant'
     permission_required = 'tenancy.change_tenant'
     cls = Tenant
     cls = Tenant
+    filter = filters.TenantFilter
     form = forms.TenantBulkEditForm
     form = forms.TenantBulkEditForm
     template_name = 'tenancy/tenant_bulk_edit.html'
     template_name = 'tenancy/tenant_bulk_edit.html'
-    default_redirect_url = 'tenancy:tenant_list'
+    default_return_url = 'tenancy:tenant_list'
 
 
 
 
 class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenant'
     permission_required = 'tenancy.delete_tenant'
     cls = 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'),
     ('607d8b', 'Dark grey'),
     ('111111', 'Black'),
     ('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):
 def expand_numeric_pattern(string):
     """
     """
     Expand a numeric pattern into a list of strings. Examples:
     Expand a numeric pattern into a list of strings. Examples:
-      'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
-      '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)
     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):
         if re.search(NUMERIC_EXPANSION_PATTERN, remnant):
             for string in expand_numeric_pattern(remnant):
             for string in expand_numeric_pattern(remnant):
                 yield "{}{}{}".format(lead, i, string)
                 yield "{}{}{}".format(lead, i, string)
@@ -61,8 +79,8 @@ def expand_numeric_pattern(string):
 def expand_ipaddress_pattern(string, family):
 def expand_ipaddress_pattern(string, family):
     """
     """
     Expand an IP address pattern into a list of strings. Examples:
     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]:
     if family not in [4, 6]:
         raise Exception("Invalid IP address family: {}".format(family))
         raise Exception("Invalid IP address family: {}".format(family))
@@ -73,8 +91,8 @@ def expand_ipaddress_pattern(string, family):
         regex = IP6_EXPANSION_PATTERN
         regex = IP6_EXPANSION_PATTERN
         base = 16
         base = 16
     lead, pattern, remnant = re.split(regex, string, maxsplit=1)
     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):
         if re.search(regex, remnant):
             for string in expand_ipaddress_pattern(remnant, family):
             for string in expand_ipaddress_pattern(remnant, family):
                 yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string])
                 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)
         super(ExpandableNameField, self).__init__(*args, **kwargs)
         if not self.help_text:
         if not self.help_text:
             self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
             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):
     def to_python(self, value):
         if re.search(NUMERIC_EXPANSION_PATTERN, value):
         if re.search(NUMERIC_EXPANSION_PATTERN, value):
@@ -265,7 +283,7 @@ class ExpandableIPAddressField(forms.CharField):
         super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
         super(ExpandableIPAddressField, self).__init__(*args, **kwargs)
         if not self.help_text:
         if not self.help_text:
             self.help_text = 'Specify a numeric range to create multiple IPs.<br />'\
             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):
     def to_python(self, value):
         # Hackish address family detection but it's all we have to work with
         # 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',
             'class': 'table table-hover',
         }
         }
 
 
-    @property
-    def model(self):
-        return self._meta.model
-
 
 
 class ToggleColumn(tables.CheckBoxColumn):
 class ToggleColumn(tables.CheckBoxColumn):
 
 

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

@@ -44,24 +44,6 @@ def startswith(value, arg):
     return str(value).startswith(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
 # Tags
 #
 #

+ 58 - 55
netbox/utilities/views.py

@@ -3,7 +3,7 @@ from django_tables2 import RequestConfig
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 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.core.urlresolvers import reverse
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import ProtectedError
 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: A django-filter FilterSet that is applied to the queryset
     filter_form: The form used to render filter options
     filter_form: The form used to render filter options
     table: The django-tables2 Table used to render the objects list
     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
     template_name: The name of the template
     """
     """
     queryset = None
     queryset = None
     filter = None
     filter = None
     filter_form = None
     filter_form = None
     table = None
     table = None
-    edit_permissions = []
     template_name = None
     template_name = None
 
 
     def get(self, request):
     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
         # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
         self.queryset = self.alter_queryset(request)
         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
         # Construct the table based on the user's permissions
         table = self.table(self.queryset)
         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
             table.base_columns['pk'].visible = True
         RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(table)
         RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(table)
 
 
         context = {
         context = {
             'table': table,
             'table': table,
+            'permissions': permissions,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
             'export_templates': ExportTemplate.objects.filter(content_type=object_ct),
             '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
     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
     fields_initial: A set of fields that will be prepopulated in the form from the request parameters
     template_name: The name of the template
     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
     model = None
     form_class = None
     form_class = None
     fields_initial = []
     fields_initial = []
     template_name = 'utilities/obj_edit.html'
     template_name = 'utilities/obj_edit.html'
-    obj_list_url = None
+    default_return_url = 'home'
 
 
     def get_object(self, kwargs):
     def get_object(self, kwargs):
         # Look up object by slug or PK. Return None if neither was provided.
         # 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).
         # Determine where to redirect the user after updating an object (or aborting an update).
         if obj.pk and hasattr(obj, 'get_absolute_url'):
         if obj.pk and hasattr(obj, 'get_absolute_url'):
             return 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):
     def get(self, request, *args, **kwargs):
 
 
@@ -166,7 +167,7 @@ class ObjectEditView(View):
             'obj': obj,
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'cancel_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(obj),
         })
         })
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
@@ -203,7 +204,7 @@ class ObjectEditView(View):
             'obj': obj,
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'cancel_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(obj),
         })
         })
 
 
 
 
@@ -226,10 +227,10 @@ class ObjectDeleteView(View):
         else:
         else:
             return get_object_or_404(self.model, pk=kwargs['pk'])
             return get_object_or_404(self.model, pk=kwargs['pk'])
 
 
-    def get_cancel_url(self, obj):
+    def get_return_url(self, obj):
         if hasattr(obj, 'get_absolute_url'):
         if hasattr(obj, 'get_absolute_url'):
             return obj.get_absolute_url()
             return obj.get_absolute_url()
-        return reverse('home')
+        return reverse(self.default_return_url)
 
 
     def get(self, request, **kwargs):
     def get(self, request, **kwargs):
 
 
@@ -243,7 +244,7 @@ class ObjectDeleteView(View):
             'obj': obj,
             'obj': obj,
             'form': form,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
-            'cancel_url': 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):
     def post(self, request, **kwargs):
@@ -272,7 +273,7 @@ class ObjectDeleteView(View):
             'obj': obj,
             'obj': obj,
             'form': form,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
-            'cancel_url': 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
     form: Form class
     model: The model of the objects being created
     model: The model of the objects being created
     template_name: The name of the template
     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
     form = None
     model = None
     model = None
     template_name = None
     template_name = None
-    redirect_url = None
+    default_return_url = 'home'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -297,7 +298,7 @@ class BulkAddView(View):
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'cancel_url': reverse(self.redirect_url),
+            'return_url': reverse(self.default_return_url),
         })
         })
 
 
     def post(self, request):
     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))
                 messages.success(request, u"Added {} {}.".format(len(new_objs), self.model._meta.verbose_name_plural))
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
                     return redirect(request.path)
                     return redirect(request.path)
-                return redirect(self.redirect_url)
+                return redirect(self.default_return_url)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
             '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
     form: Form class
     table: The django-tables2 Table used to render the list of imported objects
     table: The django-tables2 Table used to render the list of imported objects
     template_name: The name of the template
     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
     form = None
     table = None
     table = None
     template_name = None
     template_name = None
-    obj_list_url = None
+    default_return_url = None
 
 
     def get(self, request):
     def get(self, request):
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': self.form(),
             'form': self.form(),
-            'obj_list_url': self.obj_list_url,
+            'return_url': self.default_return_url,
         })
         })
 
 
     def post(self, request):
     def post(self, request):
@@ -384,7 +385,7 @@ class BulkImportView(View):
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
-            'obj_list_url': self.obj_list_url,
+            'return_url': self.default_return_url,
         })
         })
 
 
     def save_obj(self, obj):
     def save_obj(self, obj):
@@ -397,18 +398,21 @@ class BulkEditView(View):
 
 
     cls: The model of the objects being edited
     cls: The model of the objects being edited
     parent_cls: The model of the parent object (if any)
     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
     form: The form class used to edit objects in bulk
     template_name: The name of the template
     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
     cls = None
     parent_cls = None
     parent_cls = None
+    filter = None
     form = None
     form = None
     template_name = None
     template_name = None
-    default_redirect_url = None
+    default_return_url = 'home'
 
 
     def get(self):
     def get(self):
-        return redirect(self.default_redirect_url)
+        return redirect(self.default_return_url)
 
 
     def post(self, request, **kwargs):
     def post(self, request, **kwargs):
 
 
@@ -419,19 +423,17 @@ class BulkEditView(View):
             parent_obj = None
             parent_obj = None
 
 
         # Determine URL to redirect users upon modification of objects
         # 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:
         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:
         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?
         # 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:
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
             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)
                     msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
                     messages.success(self.request, msg)
                     messages.success(self.request, msg)
                     UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), 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:
         else:
             form = self.form(self.cls, initial={'pk': pk_list})
             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)
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects:
         if not selected_objects:
             messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural))
             messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural))
-            return redirect(redirect_url)
+            return redirect(return_url)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
             'selected_objects': selected_objects,
             'selected_objects': selected_objects,
-            'cancel_url': redirect_url,
+            'return_url': return_url,
         })
         })
 
 
     def update_custom_fields(self, pk_list, form, fields, nullified_fields):
     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
     cls: The model of the objects being deleted
     parent_cls: The model of the parent object (if any)
     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
     form: The form class used to delete objects in bulk
     template_name: The name of the template
     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
     cls = None
     parent_cls = None
     parent_cls = None
+    filter = None
     form = None
     form = None
     template_name = 'utilities/confirm_bulk_delete.html'
     template_name = 'utilities/confirm_bulk_delete.html'
-    default_redirect_url = None
+    default_return_url = 'home'
 
 
     def post(self, request, **kwargs):
     def post(self, request, **kwargs):
 
 
@@ -554,19 +559,17 @@ class BulkDeleteView(View):
             parent_obj = None
             parent_obj = None
 
 
         # Determine URL to redirect users upon deletion of objects
         # 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:
         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:
         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?
         # 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:
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
             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]
                     deleted_count = queryset.delete()[1][self.cls._meta.label]
                 except ProtectedError as e:
                 except ProtectedError as e:
                     handle_protectederror(list(queryset), request, 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)
                 msg = u'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
                 messages.success(request, msg)
                 messages.success(request, msg)
                 UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), 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:
         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)
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects:
         if not selected_objects:
             messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
             messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
-            return redirect(redirect_url)
+            return redirect(return_url)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
             'parent_obj': parent_obj,
             'parent_obj': parent_obj,
             'obj_type_plural': self.cls._meta.verbose_name_plural,
             'obj_type_plural': self.cls._meta.verbose_name_plural,
             'selected_objects': selected_objects,
             'selected_objects': selected_objects,
-            'cancel_url': redirect_url,
+            'return_url': return_url,
         })
         })
 
 
     def get_form(self):
     def get_form(self):