Просмотр исходного кода

Merge branch 'develop-2.1' into develop

Jeremy Stretch 8 лет назад
Родитель
Сommit
32513083b1
93 измененных файлов с 2100 добавлено и 1332 удалено
  1. 194 0
      docs/shell/intro.md
  2. 2 0
      mkdocs.yml
  3. 3 2
      netbox/circuits/api/serializers.py
  4. 1 0
      netbox/circuits/api/views.py
  5. 10 0
      netbox/circuits/constants.py
  6. 35 7
      netbox/circuits/filters.py
  7. 4 4
      netbox/circuits/forms.py
  8. 1 8
      netbox/circuits/models.py
  9. 6 23
      netbox/circuits/tables.py
  10. 9 3
      netbox/circuits/views.py
  11. 88 39
      netbox/dcim/api/serializers.py
  12. 60 16
      netbox/dcim/api/views.py
  13. 230 0
      netbox/dcim/constants.py
  14. 165 90
      netbox/dcim/filters.py
  15. 20 14
      netbox/dcim/forms.py
  16. 25 0
      netbox/dcim/migrations/0038_wireless_interfaces.py
  17. 25 0
      netbox/dcim/migrations/0039_interface_add_enabled_mtu.py
  18. 26 0
      netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py
  19. 40 0
      netbox/dcim/migrations/0041_napalm_integration.py
  20. 52 212
      netbox/dcim/models.py
  21. 75 71
      netbox/dcim/tables.py
  22. 2 0
      netbox/dcim/urls.py
  23. 88 34
      netbox/dcim/views.py
  24. 10 0
      netbox/extras/api/customfields.py
  25. 5 2
      netbox/extras/api/serializers.py
  26. 62 0
      netbox/extras/constants.py
  27. 12 2
      netbox/extras/management/commands/nbshell.py
  28. 1 56
      netbox/extras/models.py
  29. 34 8
      netbox/ipam/api/serializers.py
  30. 73 9
      netbox/ipam/api/views.py
  31. 78 0
      netbox/ipam/constants.py
  32. 17 23
      netbox/ipam/filters.py
  33. 22 8
      netbox/ipam/forms.py
  34. 25 0
      netbox/ipam/migrations/0017_ipaddress_roles.py
  35. 47 63
      netbox/ipam/models.py
  36. 37 87
      netbox/ipam/tables.py
  37. 29 0
      netbox/ipam/tests/test_api.py
  38. 38 18
      netbox/ipam/views.py
  39. 1 1
      netbox/netbox/settings.py
  40. 22 20
      netbox/netbox/views.py
  41. 25 0
      netbox/project-static/css/base.css
  42. BIN
      netbox/project-static/img/ajax-loader.gif
  43. 0 1
      netbox/project-static/js/jquery-3.2.1.min.js
  44. 5 1
      netbox/secrets/api/serializers.py
  45. 1 0
      netbox/secrets/api/views.py
  46. 7 2
      netbox/secrets/filters.py
  47. 1 9
      netbox/secrets/tables.py
  48. 6 2
      netbox/secrets/views.py
  49. 11 5
      netbox/templates/_base.html
  50. 0 23
      netbox/templates/circuits/circuit_bulk_edit.html
  51. 0 19
      netbox/templates/circuits/provider_bulk_edit.html
  52. 3 22
      netbox/templates/dcim/device.html
  53. 0 23
      netbox/templates/dcim/device_bulk_edit.html
  54. 53 0
      netbox/templates/dcim/device_config.html
  55. 2 0
      netbox/templates/dcim/device_inventory.html
  56. 46 44
      netbox/templates/dcim/device_lldp_neighbors.html
  57. 125 0
      netbox/templates/dcim/device_status.html
  58. 7 8
      netbox/templates/dcim/devicetype.html
  59. 0 19
      netbox/templates/dcim/devicetype_bulk_edit.html
  60. 1 7
      netbox/templates/dcim/inc/consoleport.html
  61. 10 2
      netbox/templates/dcim/inc/device_header.html
  62. 7 3
      netbox/templates/dcim/inc/interface.html
  63. 3 1
      netbox/templates/dcim/inc/inventoryitem.html
  64. 1 7
      netbox/templates/dcim/inc/powerport.html
  65. 0 17
      netbox/templates/dcim/interface_bulk_edit.html
  66. 0 25
      netbox/templates/dcim/interfacetemplate_bulk_edit.html
  67. 0 29
      netbox/templates/dcim/rack_bulk_edit.html
  68. 0 17
      netbox/templates/dcim/site_bulk_edit.html
  69. 4 0
      netbox/templates/inc/ajax_loader.html
  70. 0 21
      netbox/templates/ipam/aggregate_bulk_edit.html
  71. 6 0
      netbox/templates/ipam/ipaddress.html
  72. 1 0
      netbox/templates/ipam/ipaddress_bulk_add.html
  73. 0 25
      netbox/templates/ipam/ipaddress_bulk_edit.html
  74. 1 0
      netbox/templates/ipam/ipaddress_edit.html
  75. 0 25
      netbox/templates/ipam/prefix_bulk_edit.html
  76. 0 25
      netbox/templates/ipam/vlan_bulk_edit.html
  77. 0 21
      netbox/templates/ipam/vrf_bulk_edit.html
  78. 0 19
      netbox/templates/secrets/secret_bulk_edit.html
  79. 0 17
      netbox/templates/tenancy/tenant_bulk_edit.html
  80. 0 19
      netbox/templates/utilities/confirm_bulk_delete.html
  81. 4 12
      netbox/templates/utilities/confirmation_form.html
  82. 38 0
      netbox/templates/utilities/obj_bulk_delete.html
  83. 5 7
      netbox/templates/utilities/obj_bulk_edit.html
  84. 2 1
      netbox/tenancy/api/serializers.py
  85. 3 2
      netbox/tenancy/api/views.py
  86. 7 1
      netbox/tenancy/filters.py
  87. 1 1
      netbox/tenancy/forms.py
  88. 1 9
      netbox/tenancy/tables.py
  89. 6 1
      netbox/tenancy/views.py
  90. 11 0
      netbox/utilities/api.py
  91. 1 1
      netbox/utilities/forms.py
  92. 0 11
      netbox/utilities/tables.py
  93. 21 8
      netbox/utilities/views.py

+ 194 - 0
docs/shell/intro.md

@@ -0,0 +1,194 @@
+NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
+
+```
+./manage.py nbshell
+```
+
+This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/dev/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
+
+```
+$ ./manage.py nbshell
+### NetBox interactive shell (jstretch-laptop)
+### Python 2.7.6 | Django 1.11.3 | NetBox 2.1.0-dev
+### lsmodels() will show available models. Use help(<model>) for more info.
+```
+
+The function `lsmodels()` will print a list of all available NetBox models:
+
+```
+>>> lsmodels()
+DCIM:
+  ConsolePort
+  ConsolePortTemplate
+  ConsoleServerPort
+  ConsoleServerPortTemplate
+  Device
+  ...
+```
+
+## Querying Objects
+
+Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `<model>.objects.all()`, which will return a (truncated) list of all objects of that type.
+
+```
+>>> Device.objects.all()
+<QuerySet [<Device: TestDevice1>, <Device: TestDevice2>, <Device: TestDevice3>, <Device: TestDevice4>, <Device: TestDevice5>, '...(remaining elements truncated)...']>
+```
+
+Use a `for` loop to cycle through all objects in the list:
+
+```
+>>> for device in Device.objects.all():
+...   print(device.name, device.device_type)
+...
+(u'TestDevice1', <DeviceType: PacketThingy 9000>)
+(u'TestDevice2', <DeviceType: PacketThingy 9000>)
+(u'TestDevice3', <DeviceType: PacketThingy 9000>)
+(u'TestDevice4', <DeviceType: PacketThingy 9000>)
+(u'TestDevice5', <DeviceType: PacketThingy 9000>)
+...
+```
+
+To count all objects matching the query, replace `all()` with `count()`:
+
+```
+>>> Device.objects.count()
+1274
+```
+
+To retrieve a particular object (typically by its primary key or other unique field), use `get()`:
+
+```
+>>> Site.objects.get(pk=7)
+<Site: Test Lab>
+```
+
+### Filtering Querysets
+
+In most cases, you want to retrieve only a specific subset of objects. To filter a queryset, replace `all()` with `filter()` and pass one or more keyword arguments. For example:
+
+```
+>>> Device.objects.filter(status=STATUS_ACTIVE)
+<QuerySet [<Device: TestDevice1>, <Device: TestDevice2>, <Device: TestDevice3>, <Device: TestDevice8>, <Device: TestDevice9>, '...(remaining elements truncated)...']>
+```
+
+Querysets support slicing to return a specific range of objects.
+
+```
+>>> Device.objects.filter(status=STATUS_ACTIVE)[:3]
+<QuerySet [<Device: TestDevice1>, <Device: TestDevice2>, <Device: TestDevice3>]>
+```
+
+The `count()` method can be appended to the queryset to return a count of objects rather than the full list.
+
+```
+>>> Device.objects.filter(status=STATUS_ACTIVE).count()
+982
+```
+
+Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
+
+```
+>>> Device.objects.filter(tenant__name='Pied Piper')
+```
+
+This approach can span multiple levels of relations. For example, the following will return all IP addresses assigned to a device in North America:
+
+```
+>>> IPAddress.objects.filter(interface__device__site__region__slug='north-america')
+```
+
+!!! note
+    While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/dev/ref/models/querysets/) documentation.
+
+Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0":
+
+```
+>>> Device.objects.filter(interfaces__name='em0')
+```
+
+Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
+
+```
+>>> Device.objects.filter(name__icontains='testdevice')
+```
+
+Similarly, numeric fields can be filtered by values less than, greater than, and/or equal to a given value.
+
+```
+>>> VLAN.objects.filter(vid__gt=2000)
+```
+
+Multiple filters can be combined to further refine a queryset.
+
+```
+>>> VLAN.objects.filter(vid__gt=2000, name__icontains='engineering')
+```
+
+To return the inverse of a filtered queryset, use `exclude()` instead of `filter()`.
+
+```
+>>> Device.objects.count()
+4479
+>>> Device.objects.filter(status=STATUS_ACTIVE).count()
+4133
+>>> Device.objects.exclude(status=STATUS_ACTIVE).count()
+346
+```
+
+!!! info
+    The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/dev/ref/models/querysets/).
+
+## Creating and Updating Objects
+
+New objects can be created by instantiating the desired model, defining values for all required attributes, and calling `save()` on the instance.
+
+```
+>>> lab1 = Site.objects.get(pk=7)
+>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
+>>> myvlan.save()
+```
+
+Alternatively, the above can be performed as a single operation:
+
+```
+>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
+```
+
+To modify an object, retrieve it, update the desired field(s), and call `save()` again.
+
+```
+>>> vlan = VLAN.objects.get(pk=1280)
+>>> vlan.name
+u'MyNewVLAN'
+>>> vlan.name = 'BetterName'
+>>> vlan.save()
+>>> VLAN.objects.get(pk=1280).name
+u'BetterName'
+```
+
+!!! warning
+    The Django ORM provides methods to create/edit many objects at once, namely `bulk_create()` and `update()`. These are best avoided in most cases as they bypass a model's built-in validation and can easily lead to database corruption if not used carefully.
+
+## Deleting Objects
+
+To delete an object, simply call `delete()` on its instance. This will return a dictionary of all objects (including related objects) which have been deleted as a result of this operation.
+
+```
+>>> vlan
+<VLAN: 123 (BetterName)>
+>>> vlan.delete()
+(1, {u'extras.CustomFieldValue': 0, u'ipam.VLAN': 1})
+```
+
+To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
+
+```
+>>> Device.objects.filter(name__icontains='test').count()
+27
+>>> Device.objects.filter(name__icontains='test').delete()
+(35, {u'extras.CustomFieldValue': 0, u'dcim.DeviceBay': 0, u'secrets.Secret': 0, u'dcim.InterfaceConnection': 4, u'extras.ImageAttachment': 0, u'dcim.Device': 27, u'dcim.Interface': 4, u'dcim.ConsolePort': 0, u'dcim.PowerPort': 0})
+```
+
+!!! warning
+    Deletions are immediate and irreversible. Always think very carefully before calling `delete()` on an instance or queryset.

+ 2 - 0
mkdocs.yml

@@ -23,6 +23,8 @@ pages:
         - 'Authentication': 'api/authentication.md'
         - 'Authentication': 'api/authentication.md'
         - 'Working with Secrets': 'api/working-with-secrets.md'
         - 'Working with Secrets': 'api/working-with-secrets.md'
         - 'Examples': 'api/examples.md'
         - 'Examples': 'api/examples.md'
+    - 'Shell':
+        - 'Introduction': 'shell/intro.md'
 
 
 markdown_extensions:
 markdown_extensions:
     - admonition:
     - admonition:

+ 3 - 2
netbox/circuits/api/serializers.py

@@ -6,6 +6,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
+from utilities.api import ModelValidationMixin
 
 
 
 
 #
 #
@@ -44,7 +45,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
 # Circuit types
 # Circuit types
 #
 #
 
 
-class CircuitTypeSerializer(serializers.ModelSerializer):
+class CircuitTypeSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
@@ -110,7 +111,7 @@ class CircuitTerminationSerializer(serializers.ModelSerializer):
         ]
         ]
 
 
 
 
-class WritableCircuitTerminationSerializer(serializers.ModelSerializer):
+class WritableCircuitTerminationSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination

+ 1 - 0
netbox/circuits/api/views.py

@@ -43,6 +43,7 @@ class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 class CircuitTypeViewSet(ModelViewSet):
 class CircuitTypeViewSet(ModelViewSet):
     queryset = CircuitType.objects.all()
     queryset = CircuitType.objects.all()
     serializer_class = serializers.CircuitTypeSerializer
     serializer_class = serializers.CircuitTypeSerializer
+    filter_class = filters.CircuitTypeFilter
 
 
 
 
 #
 #

+ 10 - 0
netbox/circuits/constants.py

@@ -0,0 +1,10 @@
+from __future__ import unicode_literals
+
+
+# CircuitTermination sides
+TERM_SIDE_A = 'A'
+TERM_SIDE_Z = 'Z'
+TERM_SIDE_CHOICES = (
+    (TERM_SIDE_A, 'A'),
+    (TERM_SIDE_Z, 'Z'),
+)

+ 35 - 7
netbox/circuits/filters.py

@@ -31,7 +31,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['name', 'account', 'asn']
+        fields = ['name', 'slug', 'asn', 'account']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -39,10 +39,19 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             Q(name__icontains=value) |
             Q(account__icontains=value) |
             Q(account__icontains=value) |
+            Q(noc_contact__icontains=value) |
+            Q(admin_contact__icontains=value) |
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
 
 
 
 
+class CircuitTypeFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = CircuitType
+        fields = ['name', 'slug']
+
+
 class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -50,7 +59,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     provider_id = django_filters.ModelMultipleChoiceFilter(
     provider_id = django_filters.ModelMultipleChoiceFilter(
-        name='provider',
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         label='Provider (ID)',
         label='Provider (ID)',
     )
     )
@@ -61,7 +69,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Provider (slug)',
         label='Provider (slug)',
     )
     )
     type_id = django_filters.ModelMultipleChoiceFilter(
     type_id = django_filters.ModelMultipleChoiceFilter(
-        name='type',
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         label='Circuit type (ID)',
         label='Circuit type (ID)',
     )
     )
@@ -72,7 +79,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Circuit type (slug)',
         label='Circuit type (slug)',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -96,7 +102,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['install_date']
+        fields = ['cid', 'install_date', 'commit_rate']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -111,12 +117,34 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 
 
 class CircuitTerminationFilter(django_filters.FilterSet):
 class CircuitTerminationFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     circuit_id = django_filters.ModelMultipleChoiceFilter(
     circuit_id = django_filters.ModelMultipleChoiceFilter(
-        name='circuit',
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         label='Circuit',
         label='Circuit',
     )
     )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
-        fields = ['term_side', 'site']
+        fields = ['term_side', 'port_speed', 'upstream_speed', 'xconnect_id']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(circuit__cid__icontains=value) |
+            Q(xconnect_id__icontains=value) |
+            Q(pp_info__icontains=value)
+        ).distinct()

+ 4 - 4
netbox/circuits/forms.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 from django import forms
 from django import forms
 from django.db.models import Count
 from django.db.models import Count
 
 
-from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
+from dcim.models import Site, Device, Interface, Rack
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -210,7 +210,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         )
         )
     )
     )
     interface = ChainedModelChoiceField(
     interface = ChainedModelChoiceField(
-        queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
+        queryset=Interface.objects.connectable().select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
             'circuit_termination', 'connected_as_a', 'connected_as_b'
         ),
         ),
         chains=(
         chains=(
@@ -220,7 +220,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         label='Interface',
         label='Interface',
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
             api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
-            disabled_indicator='connection'
+            disabled_indicator='is_connected'
         )
         )
     )
     )
 
 
@@ -244,7 +244,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         # Initialize helper selectors
         # Initialize helper selectors
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
         if instance and instance.interface is not None:
         if instance and instance.interface is not None:
-            initial = kwargs.get('initial', {})
+            initial = kwargs.get('initial', {}).copy()
             initial['rack'] = instance.interface.device.rack
             initial['rack'] = instance.interface.device.rack
             initial['device'] = instance.interface.device
             initial['device'] = instance.interface.device
             kwargs['initial'] = initial
             kwargs['initial'] = initial

+ 1 - 8
netbox/circuits/models.py

@@ -10,14 +10,7 @@ from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.utils import csv_format
 from utilities.utils import csv_format
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-
-
-TERM_SIDE_A = 'A'
-TERM_SIDE_Z = 'Z'
-TERM_SIDE_CHOICES = (
-    (TERM_SIDE_A, 'A'),
-    (TERM_SIDE_Z, 'Z'),
-)
+from .constants import *
 
 
 
 
 def humanize_speed(speed):
 def humanize_speed(speed):

+ 6 - 23
netbox/circuits/tables.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from utilities.tables import BaseTable, SearchTable, ToggleColumn
+from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 from .models import Circuit, CircuitType, Provider
 
 
 
 
@@ -21,19 +21,18 @@ CIRCUITTYPE_ACTIONS = """
 class ProviderTable(BaseTable):
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn()
     name = tables.LinkColumn()
-    circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Provider
         model = Provider
-        fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
+        fields = ('pk', 'name', 'asn', 'account',)
 
 
 
 
-class ProviderSearchTable(SearchTable):
-    name = tables.LinkColumn()
+class ProviderDetailTable(ProviderTable):
+    circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
 
-    class Meta(SearchTable.Meta):
+    class Meta(ProviderTable.Meta):
         model = Provider
         model = Provider
-        fields = ('name', 'asn', 'account')
+        fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
 
 
 #
 #
@@ -74,19 +73,3 @@ class CircuitTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
         fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
         fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
-
-
-class CircuitSearchTable(SearchTable):
-    cid = tables.LinkColumn(verbose_name='ID')
-    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-    a_side = tables.LinkColumn(
-        'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')]
-    )
-    z_side = tables.LinkColumn(
-        'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')]
-    )
-
-    class Meta(SearchTable.Meta):
-        model = Circuit
-        fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

+ 9 - 3
netbox/circuits/views.py

@@ -26,7 +26,7 @@ class ProviderListView(ObjectListView):
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filter = filters.ProviderFilter
     filter = filters.ProviderFilter
     filter_form = forms.ProviderFilterForm
     filter_form = forms.ProviderFilterForm
-    table = tables.ProviderTable
+    table = tables.ProviderDetailTable
     template_name = 'circuits/provider_list.html'
     template_name = 'circuits/provider_list.html'
 
 
 
 
@@ -78,8 +78,8 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_provider'
     permission_required = 'circuits.change_provider'
     cls = Provider
     cls = Provider
     filter = filters.ProviderFilter
     filter = filters.ProviderFilter
+    table = tables.ProviderTable
     form = forms.ProviderBulkEditForm
     form = forms.ProviderBulkEditForm
-    template_name = 'circuits/provider_bulk_edit.html'
     default_return_url = 'circuits:provider_list'
     default_return_url = 'circuits:provider_list'
 
 
 
 
@@ -87,6 +87,7 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_provider'
     permission_required = 'circuits.delete_provider'
     cls = Provider
     cls = Provider
     filter = filters.ProviderFilter
     filter = filters.ProviderFilter
+    table = tables.ProviderTable
     default_return_url = 'circuits:provider_list'
     default_return_url = 'circuits:provider_list'
 
 
 
 
@@ -116,6 +117,8 @@ class CircuitTypeEditView(CircuitTypeCreateView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     permission_required = 'circuits.delete_circuittype'
     cls = CircuitType
     cls = CircuitType
+    queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
+    table = tables.CircuitTypeTable
     default_return_url = 'circuits:circuittype_list'
     default_return_url = 'circuits:circuittype_list'
 
 
 
 
@@ -182,16 +185,19 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
 class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
     cls = Circuit
     cls = Circuit
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
+    table = tables.CircuitTable
     form = forms.CircuitBulkEditForm
     form = forms.CircuitBulkEditForm
-    template_name = 'circuits/circuit_bulk_edit.html'
     default_return_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
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
+    table = tables.CircuitTable
     default_return_url = 'circuits:circuit_list'
     default_return_url = 'circuits:circuit_list'
 
 
 
 

+ 88 - 39
netbox/dcim/api/serializers.py

@@ -1,19 +1,21 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
+from collections import OrderedDict
 
 
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
 
 
 from ipam.models import IPAddress
 from ipam.models import IPAddress
+from circuits.models import Circuit, CircuitTermination
 from dcim.models import (
 from dcim.models import (
     CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
     DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
-    InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
+    InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate,
+    PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
     RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
     RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer
+from utilities.api import ChoiceFieldSerializer, ModelValidationMixin
 
 
 
 
 #
 #
@@ -36,7 +38,7 @@ class RegionSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'parent']
         fields = ['id', 'name', 'slug', 'parent']
 
 
 
 
-class WritableRegionSerializer(serializers.ModelSerializer):
+class WritableRegionSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
@@ -98,7 +100,7 @@ class NestedRackGroupSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name', 'slug']
         fields = ['id', 'url', 'name', 'slug']
 
 
 
 
-class WritableRackGroupSerializer(serializers.ModelSerializer):
+class WritableRackGroupSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
@@ -109,7 +111,7 @@ class WritableRackGroupSerializer(serializers.ModelSerializer):
 # Rack roles
 # Rack roles
 #
 #
 
 
-class RackRoleSerializer(serializers.ModelSerializer):
+class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
@@ -174,6 +176,9 @@ class WritableRackSerializer(CustomFieldModelSerializer):
             validator.set_context(self)
             validator.set_context(self)
             validator(data)
             validator(data)
 
 
+        # Enforce model validation
+        super(WritableRackSerializer, self).validate(data)
+
         return data
         return data
 
 
 
 
@@ -211,7 +216,7 @@ class RackReservationSerializer(serializers.ModelSerializer):
         fields = ['id', 'rack', 'units', 'created', 'user', 'description']
         fields = ['id', 'rack', 'units', 'created', 'user', 'description']
 
 
 
 
-class WritableRackReservationSerializer(serializers.ModelSerializer):
+class WritableRackReservationSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
@@ -222,7 +227,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer):
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerSerializer(serializers.ModelSerializer):
+class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
@@ -287,7 +292,7 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableConsolePortTemplateSerializer(serializers.ModelSerializer):
+class WritableConsolePortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
@@ -306,7 +311,7 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
+class WritableConsoleServerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
@@ -325,7 +330,7 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritablePowerPortTemplateSerializer(serializers.ModelSerializer):
+class WritablePowerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
@@ -344,7 +349,7 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer):
+class WritablePowerOutletTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
@@ -364,7 +369,7 @@ class InterfaceTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
         fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
 
 
 
 
-class WritableInterfaceTemplateSerializer(serializers.ModelSerializer):
+class WritableInterfaceTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
@@ -383,7 +388,7 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer):
         fields = ['id', 'device_type', 'name']
         fields = ['id', 'device_type', 'name']
 
 
 
 
-class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer):
+class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
@@ -394,7 +399,7 @@ class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer):
 # Device roles
 # Device roles
 #
 #
 
 
-class DeviceRoleSerializer(serializers.ModelSerializer):
+class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
@@ -413,11 +418,11 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformSerializer(serializers.ModelSerializer):
+class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['id', 'name', 'slug', 'rpc_client']
+        fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
 
 
 
 
 class NestedPlatformSerializer(serializers.ModelSerializer):
 class NestedPlatformSerializer(serializers.ModelSerializer):
@@ -496,6 +501,9 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
             validator.set_context(self)
             validator.set_context(self)
             validator(data)
             validator(data)
 
 
+        # Enforce model validation
+        super(WritableDeviceSerializer, self).validate(data)
+
         return data
         return data
 
 
 
 
@@ -512,7 +520,7 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_console']
         read_only_fields = ['connected_console']
 
 
 
 
-class WritableConsoleServerPortSerializer(serializers.ModelSerializer):
+class WritableConsoleServerPortSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
@@ -532,7 +540,7 @@ class ConsolePortSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
         fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
 
 
 
 
-class WritableConsolePortSerializer(serializers.ModelSerializer):
+class WritableConsolePortSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
@@ -552,7 +560,7 @@ class PowerOutletSerializer(serializers.ModelSerializer):
         read_only_fields = ['connected_port']
         read_only_fields = ['connected_port']
 
 
 
 
-class WritablePowerOutletSerializer(serializers.ModelSerializer):
+class WritablePowerOutletSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
@@ -572,7 +580,7 @@ class PowerPortSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
         fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
 
 
 
 
-class WritablePowerPortSerializer(serializers.ModelSerializer):
+class WritablePowerPortSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
@@ -591,28 +599,58 @@ class NestedInterfaceSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'name']
         fields = ['id', 'url', 'name']
 
 
 
 
+class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
+
+    class Meta:
+        model = Circuit
+        fields = ['id', 'url', 'cid']
+
+
+class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
+    circuit = InterfaceNestedCircuitSerializer()
+
+    class Meta:
+        model = CircuitTermination
+        fields = [
+            'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
+        ]
+
+
 class InterfaceSerializer(serializers.ModelSerializer):
 class InterfaceSerializer(serializers.ModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
     lag = NestedInterfaceSerializer()
     lag = NestedInterfaceSerializer()
-    connection = serializers.SerializerMethodField(read_only=True)
-    connected_interface = serializers.SerializerMethodField(read_only=True)
+    is_connected = serializers.SerializerMethodField(read_only=True)
+    interface_connection = serializers.SerializerMethodField(read_only=True)
+    circuit_termination = InterfaceCircuitTerminationSerializer()
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection',
-            'connected_interface',
+            'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'is_connected', 'interface_connection', 'circuit_termination',
         ]
         ]
 
 
-    def get_connection(self, obj):
+    def get_is_connected(self, obj):
+        """
+        Return True if the interface has a connected interface or circuit termination.
+        """
         if obj.connection:
         if obj.connection:
-            return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data
-        return None
+            return True
+        try:
+            circuit_termination = obj.circuit_termination
+            return True
+        except CircuitTermination.DoesNotExist:
+            pass
+        return False
 
 
-    def get_connected_interface(self, obj):
-        if obj.connected_interface:
-            return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data
+    def get_interface_connection(self, obj):
+        if obj.connection:
+            return OrderedDict((
+                ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data),
+                ('status', obj.connection.connection_status),
+            ))
         return None
         return None
 
 
 
 
@@ -624,14 +662,19 @@ class PeerInterfaceSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
+        fields = [
+            'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
+            'description',
+        ]
 
 
 
 
-class WritableInterfaceSerializer(serializers.ModelSerializer):
+class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
+        fields = [
+            'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+        ]
 
 
 
 
 #
 #
@@ -647,7 +690,7 @@ class DeviceBaySerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'installed_device']
         fields = ['id', 'device', 'name', 'installed_device']
 
 
 
 
-class WritableDeviceBaySerializer(serializers.ModelSerializer):
+class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
@@ -664,14 +707,20 @@ class InventoryItemSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
+        fields = [
+            'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+            'description',
+        ]
 
 
 
 
-class WritableInventoryItemSerializer(serializers.ModelSerializer):
+class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
+        fields = [
+            'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+            'description',
+        ]
 
 
 
 
 #
 #
@@ -696,7 +745,7 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'connection_status']
         fields = ['id', 'url', 'connection_status']
 
 
 
 
-class WritableInterfaceConnectionSerializer(serializers.ModelSerializer):
+class WritableInterfaceConnectionSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = InterfaceConnection
         model = InterfaceConnection

+ 60 - 16
netbox/dcim/api/views.py

@@ -1,4 +1,5 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
+from collections import OrderedDict
 
 
 from rest_framework.decorators import detail_route
 from rest_framework.decorators import detail_route
 from rest_framework.mixins import ListModelMixin
 from rest_framework.mixins import ListModelMixin
@@ -7,6 +8,7 @@ from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
 from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
 
 
 from django.conf import settings
 from django.conf import settings
+from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 
 
 from dcim.models import (
 from dcim.models import (
@@ -32,6 +34,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = Region.objects.all()
     queryset = Region.objects.all()
     serializer_class = serializers.RegionSerializer
     serializer_class = serializers.RegionSerializer
     write_serializer_class = serializers.WritableRegionSerializer
     write_serializer_class = serializers.WritableRegionSerializer
+    filter_class = filters.RegionFilter
 
 
 
 
 #
 #
@@ -73,6 +76,7 @@ class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
 class RackRoleViewSet(ModelViewSet):
 class RackRoleViewSet(ModelViewSet):
     queryset = RackRole.objects.all()
     queryset = RackRole.objects.all()
     serializer_class = serializers.RackRoleSerializer
     serializer_class = serializers.RackRoleSerializer
+    filter_class = filters.RackRoleFilter
 
 
 
 
 #
 #
@@ -128,6 +132,7 @@ class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
 class ManufacturerViewSet(ModelViewSet):
 class ManufacturerViewSet(ModelViewSet):
     queryset = Manufacturer.objects.all()
     queryset = Manufacturer.objects.all()
     serializer_class = serializers.ManufacturerSerializer
     serializer_class = serializers.ManufacturerSerializer
+    filter_class = filters.ManufacturerFilter
 
 
 
 
 #
 #
@@ -194,6 +199,7 @@ class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
 class DeviceRoleViewSet(ModelViewSet):
 class DeviceRoleViewSet(ModelViewSet):
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     serializer_class = serializers.DeviceRoleSerializer
     serializer_class = serializers.DeviceRoleSerializer
+    filter_class = filters.DeviceRoleFilter
 
 
 
 
 #
 #
@@ -203,6 +209,7 @@ class DeviceRoleViewSet(ModelViewSet):
 class PlatformViewSet(ModelViewSet):
 class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     serializer_class = serializers.PlatformSerializer
     serializer_class = serializers.PlatformSerializer
+    filter_class = filters.PlatformFilter
 
 
 
 
 #
 #
@@ -219,27 +226,64 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     write_serializer_class = serializers.WritableDeviceSerializer
     write_serializer_class = serializers.WritableDeviceSerializer
     filter_class = filters.DeviceFilter
     filter_class = filters.DeviceFilter
 
 
-    @detail_route(url_path='lldp-neighbors')
-    def lldp_neighbors(self, request, pk):
+    @detail_route(url_path='napalm')
+    def napalm(self, request, pk):
         """
         """
-        Retrieve live LLDP neighbors of a device
+        Execute a NAPALM method on a Device
         """
         """
         device = get_object_or_404(Device, pk=pk)
         device = get_object_or_404(Device, pk=pk)
         if not device.primary_ip:
         if not device.primary_ip:
-            raise ServiceUnavailable("No IP configured for this device.")
-
-        RPC = device.get_rpc_client()
-        if not RPC:
-            raise ServiceUnavailable("No RPC client available for this platform ({}).".format(device.platform))
-
-        # Connect to device and retrieve inventory info
+            raise ServiceUnavailable("This device does not have a primary IP address configured.")
+        if device.platform is None:
+            raise ServiceUnavailable("No platform is configured for this device.")
+        if not device.platform.napalm_driver:
+            raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format(
+                device.platform
+            ))
+
+        # Check that NAPALM is installed and verify the configured driver
         try:
         try:
-            with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
-                lldp_neighbors = rpc_client.get_lldp_neighbors()
-        except:
-            raise ServiceUnavailable("Error connecting to the remote device.")
-
-        return Response(lldp_neighbors)
+            import napalm
+            from napalm_base.exceptions import ConnectAuthError, ModuleImportError
+        except ImportError:
+            raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
+        try:
+            driver = napalm.get_network_driver(device.platform.napalm_driver)
+        except ModuleImportError:
+            raise ServiceUnavailable("NAPALM driver for platform {} not found: {}.".format(
+                device.platform, device.platform.napalm_driver
+            ))
+
+        # Verify user permission
+        if not request.user.has_perm('dcim.napalm_read'):
+            return HttpResponseForbidden()
+
+        # Validate requested NAPALM methods
+        napalm_methods = request.GET.getlist('method')
+        for method in napalm_methods:
+            if not hasattr(driver, method):
+                return HttpResponseBadRequest("Unknown NAPALM method: {}".format(method))
+            elif not method.startswith('get_'):
+                return HttpResponseBadRequest("Unsupported NAPALM method: {}".format(method))
+
+        # Connect to the device and execute the requested methods
+        # TODO: Improve error handling
+        response = OrderedDict([(m, None) for m in napalm_methods])
+        ip_address = str(device.primary_ip.address.ip)
+        d = driver(
+            hostname=ip_address,
+            username=settings.NETBOX_USERNAME,
+            password=settings.NETBOX_PASSWORD
+        )
+        try:
+            d.open()
+            for method in napalm_methods:
+                response[method] = getattr(d, method)()
+        except Exception as e:
+            raise ServiceUnavailable("Error connecting to the device: {}".format(e))
+
+        d.close()
+        return Response(response)
 
 
 
 
 #
 #

+ 230 - 0
netbox/dcim/constants.py

@@ -0,0 +1,230 @@
+from __future__ import unicode_literals
+
+
+# Rack types
+RACK_TYPE_2POST = 100
+RACK_TYPE_4POST = 200
+RACK_TYPE_CABINET = 300
+RACK_TYPE_WALLFRAME = 1000
+RACK_TYPE_WALLCABINET = 1100
+RACK_TYPE_CHOICES = (
+    (RACK_TYPE_2POST, '2-post frame'),
+    (RACK_TYPE_4POST, '4-post frame'),
+    (RACK_TYPE_CABINET, '4-post cabinet'),
+    (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
+    (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
+)
+
+# Rack widths
+RACK_WIDTH_19IN = 19
+RACK_WIDTH_23IN = 23
+RACK_WIDTH_CHOICES = (
+    (RACK_WIDTH_19IN, '19 inches'),
+    (RACK_WIDTH_23IN, '23 inches'),
+)
+
+# Rack faces
+RACK_FACE_FRONT = 0
+RACK_FACE_REAR = 1
+RACK_FACE_CHOICES = [
+    [RACK_FACE_FRONT, 'Front'],
+    [RACK_FACE_REAR, 'Rear'],
+]
+
+# Parent/child device roles
+SUBDEVICE_ROLE_PARENT = True
+SUBDEVICE_ROLE_CHILD = False
+SUBDEVICE_ROLE_CHOICES = (
+    (None, 'None'),
+    (SUBDEVICE_ROLE_PARENT, 'Parent'),
+    (SUBDEVICE_ROLE_CHILD, 'Child'),
+)
+
+# Interface ordering schemes (for device types)
+IFACE_ORDERING_POSITION = 1
+IFACE_ORDERING_NAME = 2
+IFACE_ORDERING_CHOICES = [
+    [IFACE_ORDERING_POSITION, 'Slot/position'],
+    [IFACE_ORDERING_NAME, 'Name (alphabetically)']
+]
+
+# Interface form factors
+# Virtual
+IFACE_FF_VIRTUAL = 0
+IFACE_FF_LAG = 200
+# Ethernet
+IFACE_FF_100ME_FIXED = 800
+IFACE_FF_1GE_FIXED = 1000
+IFACE_FF_1GE_GBIC = 1050
+IFACE_FF_1GE_SFP = 1100
+IFACE_FF_10GE_FIXED = 1150
+IFACE_FF_10GE_SFP_PLUS = 1200
+IFACE_FF_10GE_XFP = 1300
+IFACE_FF_10GE_XENPAK = 1310
+IFACE_FF_10GE_X2 = 1320
+IFACE_FF_25GE_SFP28 = 1350
+IFACE_FF_40GE_QSFP_PLUS = 1400
+IFACE_FF_100GE_CFP = 1500
+IFACE_FF_100GE_QSFP28 = 1600
+# Wireless
+IFACE_FF_80211A = 2600
+IFACE_FF_80211G = 2610
+IFACE_FF_80211N = 2620
+IFACE_FF_80211AC = 2630
+IFACE_FF_80211AD = 2640
+# Fibrechannel
+IFACE_FF_1GFC_SFP = 3010
+IFACE_FF_2GFC_SFP = 3020
+IFACE_FF_4GFC_SFP = 3040
+IFACE_FF_8GFC_SFP_PLUS = 3080
+IFACE_FF_16GFC_SFP_PLUS = 3160
+# Serial
+IFACE_FF_T1 = 4000
+IFACE_FF_E1 = 4010
+IFACE_FF_T3 = 4040
+IFACE_FF_E3 = 4050
+# Stacking
+IFACE_FF_STACKWISE = 5000
+IFACE_FF_STACKWISE_PLUS = 5050
+IFACE_FF_FLEXSTACK = 5100
+IFACE_FF_FLEXSTACK_PLUS = 5150
+IFACE_FF_JUNIPER_VCP = 5200
+# Other
+IFACE_FF_OTHER = 32767
+
+IFACE_FF_CHOICES = [
+    [
+        'Virtual interfaces',
+        [
+            [IFACE_FF_VIRTUAL, 'Virtual'],
+            [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
+        ]
+    ],
+    [
+        'Ethernet (fixed)',
+        [
+            [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
+            [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
+            [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
+        ]
+    ],
+    [
+        'Ethernet (modular)',
+        [
+            [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
+            [IFACE_FF_1GE_SFP, 'SFP (1GE)'],
+            [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
+            [IFACE_FF_10GE_XFP, 'XFP (10GE)'],
+            [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
+            [IFACE_FF_10GE_X2, 'X2 (10GE)'],
+            [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
+            [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
+            [IFACE_FF_100GE_CFP, 'CFP (100GE)'],
+            [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
+        ]
+    ],
+    [
+        'Wireless',
+        [
+            [IFACE_FF_80211A, 'IEEE 802.11a'],
+            [IFACE_FF_80211G, 'IEEE 802.11b/g'],
+            [IFACE_FF_80211N, 'IEEE 802.11n'],
+            [IFACE_FF_80211AC, 'IEEE 802.11ac'],
+            [IFACE_FF_80211AD, 'IEEE 802.11ad'],
+        ]
+    ],
+    [
+        'FibreChannel',
+        [
+            [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
+            [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
+            [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
+            [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
+            [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
+        ]
+    ],
+    [
+        'Serial',
+        [
+            [IFACE_FF_T1, 'T1 (1.544 Mbps)'],
+            [IFACE_FF_E1, 'E1 (2.048 Mbps)'],
+            [IFACE_FF_T3, 'T3 (45 Mbps)'],
+            [IFACE_FF_E3, 'E3 (34 Mbps)'],
+        ]
+    ],
+    [
+        'Stacking',
+        [
+            [IFACE_FF_STACKWISE, 'Cisco StackWise'],
+            [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
+            [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
+            [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
+            [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
+        ]
+    ],
+    [
+        'Other',
+        [
+            [IFACE_FF_OTHER, 'Other'],
+        ]
+    ],
+]
+
+VIRTUAL_IFACE_TYPES = [
+    IFACE_FF_VIRTUAL,
+    IFACE_FF_LAG,
+]
+
+WIRELESS_IFACE_TYPES = [
+    IFACE_FF_80211A,
+    IFACE_FF_80211G,
+    IFACE_FF_80211N,
+    IFACE_FF_80211AC,
+    IFACE_FF_80211AD,
+]
+
+NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
+
+# Device statuses
+STATUS_OFFLINE = 0
+STATUS_ACTIVE = 1
+STATUS_PLANNED = 2
+STATUS_STAGED = 3
+STATUS_FAILED = 4
+STATUS_INVENTORY = 5
+STATUS_CHOICES = [
+    [STATUS_ACTIVE, 'Active'],
+    [STATUS_OFFLINE, 'Offline'],
+    [STATUS_PLANNED, 'Planned'],
+    [STATUS_STAGED, 'Staged'],
+    [STATUS_FAILED, 'Failed'],
+    [STATUS_INVENTORY, 'Inventory'],
+]
+
+# Bootstrap CSS classes for device stasuses
+DEVICE_STATUS_CLASSES = {
+    0: 'warning',
+    1: 'success',
+    2: 'info',
+    3: 'primary',
+    4: 'danger',
+    5: 'default',
+}
+
+# Console/power/interface connection statuses
+CONNECTION_STATUS_PLANNED = False
+CONNECTION_STATUS_CONNECTED = True
+CONNECTION_STATUS_CHOICES = [
+    [CONNECTION_STATUS_PLANNED, 'Planned'],
+    [CONNECTION_STATUS_CONNECTED, 'Connected'],
+]
+
+# Platform -> RPC client mappings
+RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos'
+RPC_CLIENT_CISCO_IOS = 'cisco-ios'
+RPC_CLIENT_OPENGEAR = 'opengear'
+RPC_CLIENT_CHOICES = [
+    [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'],
+    [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'],
+    [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'],
+]

+ 165 - 90
netbox/dcim/filters.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 import django_filters
 import django_filters
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
+from django.contrib.auth.models import User
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
@@ -11,11 +12,28 @@ from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
-    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
+    InterfaceTemplate, Manufacturer, InventoryItem, NONCONNECTABLE_IFACE_TYPES, Platform, PowerOutlet,
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site,
+    VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
 )
 )
 
 
 
 
+class RegionFilter(django_filters.FilterSet):
+    parent_id = NullableModelMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        label='Parent region (ID)',
+    )
+    parent = NullableModelMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        label='Parent region (slug)',
+    )
+
+    class Meta:
+        model = Region
+        fields = ['name', 'slug']
+
+
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -23,23 +41,19 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     region_id = NullableModelMultipleChoiceFilter(
     region_id = NullableModelMultipleChoiceFilter(
-        name='region',
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = NullableModelMultipleChoiceFilter(
     region = NullableModelMultipleChoiceFilter(
-        name='region',
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
     tenant = NullableModelMultipleChoiceFilter(
     tenant = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
@@ -47,7 +61,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['q', 'name', 'facility', 'asn']
+        fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -57,6 +71,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
             Q(facility__icontains=value) |
             Q(facility__icontains=value) |
             Q(physical_address__icontains=value) |
             Q(physical_address__icontains=value) |
             Q(shipping_address__icontains=value) |
             Q(shipping_address__icontains=value) |
+            Q(contact_name__icontains=value) |
+            Q(contact_phone__icontains=value) |
+            Q(contact_email__icontains=value) |
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
         try:
         try:
@@ -68,7 +85,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class RackGroupFilter(django_filters.FilterSet):
 class RackGroupFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
@@ -81,7 +97,14 @@ class RackGroupFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = ['name']
+        fields = ['site_id', 'name', 'slug']
+
+
+class RackRoleFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = RackRole
+        fields = ['name', 'slug', 'color']
 
 
 
 
 class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -91,7 +114,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
@@ -102,7 +124,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (slug)',
         label='Site (slug)',
     )
     )
     group_id = NullableModelMultipleChoiceFilter(
     group_id = NullableModelMultipleChoiceFilter(
-        name='group',
         queryset=RackGroup.objects.all(),
         queryset=RackGroup.objects.all(),
         label='Group (ID)',
         label='Group (ID)',
     )
     )
@@ -113,7 +134,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Group',
         label='Group',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -124,7 +144,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
     role_id = NullableModelMultipleChoiceFilter(
     role_id = NullableModelMultipleChoiceFilter(
-        name='role',
         queryset=RackRole.objects.all(),
         queryset=RackRole.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
@@ -137,7 +156,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['u_height']
+        fields = ['facility_id', 'type', 'width', 'u_height', 'desc_units']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -155,6 +174,10 @@ class RackReservationFilter(django_filters.FilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    rack_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Rack.objects.all(),
+        label='Rack (ID)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='rack__site',
         name='rack__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -177,15 +200,20 @@ class RackReservationFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group',
         label='Group',
     )
     )
-    rack_id = django_filters.ModelMultipleChoiceFilter(
-        name='rack',
-        queryset=Rack.objects.all(),
-        label='Rack (ID)',
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        name='user',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User (name)',
     )
     )
 
 
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
-        fields = ['rack', 'user']
+        fields = ['created']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -198,6 +226,13 @@ class RackReservationFilter(django_filters.FilterSet):
         )
         )
 
 
 
 
+class ManufacturerFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = Manufacturer
+        fields = ['name', 'slug']
+
+
 class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -205,7 +240,6 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
-        name='manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         label='Manufacturer (ID)',
         label='Manufacturer (ID)',
     )
     )
@@ -219,7 +253,8 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
-            'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
+            'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
+            'is_network_device', 'subdevice_role',
         ]
         ]
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -235,16 +270,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
     devicetype_id = django_filters.ModelMultipleChoiceFilter(
-        name='device_type',
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         label='Device type (ID)',
         label='Device type (ID)',
     )
     )
-    devicetype = django_filters.ModelMultipleChoiceFilter(
-        name='device_type',
-        queryset=DeviceType.objects.all(),
-        to_field_name='name',
-        label='Device type (name)',
-    )
 
 
 
 
 class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
 class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
@@ -279,7 +307,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['name', 'form_factor']
+        fields = ['name', 'form_factor', 'mgmt_only']
 
 
 
 
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
@@ -289,39 +317,43 @@ class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
         fields = ['name']
         fields = ['name']
 
 
 
 
+class DeviceRoleFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = DeviceRole
+        fields = ['name', 'slug', 'color']
+
+
+class PlatformFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = Platform
+        fields = ['name', 'slug']
+
+
 class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
-    mac_address = django_filters.CharFilter(
-        method='_mac_address',
-        label='MAC address',
-    )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
-        queryset=Site.objects.all(),
-        label='Site (ID)',
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        name='device_type__manufacturer',
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
     )
     )
-    site = django_filters.ModelMultipleChoiceFilter(
-        name='site__slug',
-        queryset=Site.objects.all(),
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        name='device_type__manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Site name (slug)',
-    )
-    rack_group_id = django_filters.ModelMultipleChoiceFilter(
-        name='rack__group',
-        queryset=RackGroup.objects.all(),
-        label='Rack group (ID)',
+        label='Manufacturer (slug)',
     )
     )
-    rack_id = NullableModelMultipleChoiceFilter(
-        name='rack',
-        queryset=Rack.objects.all(),
-        label='Rack (ID)',
+    device_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=DeviceType.objects.all(),
+        label='Device type (ID)',
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
-        name='device_role',
+        name='device_role_id',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
@@ -332,7 +364,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Role (slug)',
         label='Role (slug)',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -342,21 +373,35 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
-    device_type_id = django_filters.ModelMultipleChoiceFilter(
-        name='device_type',
-        queryset=DeviceType.objects.all(),
-        label='Device type (ID)',
+    platform_id = NullableModelMultipleChoiceFilter(
+        queryset=Platform.objects.all(),
+        label='Platform (ID)',
     )
     )
-    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
-        name='device_type__manufacturer',
-        queryset=Manufacturer.objects.all(),
-        label='Manufacturer (ID)',
+    platform = NullableModelMultipleChoiceFilter(
+        name='platform',
+        queryset=Platform.objects.all(),
+        to_field_name='slug',
+        label='Platform (slug)',
     )
     )
-    manufacturer = django_filters.ModelMultipleChoiceFilter(
-        name='device_type__manufacturer__slug',
-        queryset=Manufacturer.objects.all(),
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='site__slug',
+        queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Manufacturer (slug)',
+        label='Site name (slug)',
+    )
+    rack_group_id = django_filters.ModelMultipleChoiceFilter(
+        name='rack__group',
+        queryset=RackGroup.objects.all(),
+        label='Rack group (ID)',
+    )
+    rack_id = NullableModelMultipleChoiceFilter(
+        name='rack',
+        queryset=Rack.objects.all(),
+        label='Rack (ID)',
     )
     )
     model = django_filters.ModelMultipleChoiceFilter(
     model = django_filters.ModelMultipleChoiceFilter(
         name='device_type__slug',
         name='device_type__slug',
@@ -364,16 +409,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Device model (slug)',
         label='Device model (slug)',
     )
     )
-    platform_id = NullableModelMultipleChoiceFilter(
-        name='platform',
-        queryset=Platform.objects.all(),
-        label='Platform (ID)',
+    status = django_filters.MultipleChoiceFilter(
+        choices=STATUS_CHOICES
     )
     )
-    platform = NullableModelMultipleChoiceFilter(
-        name='platform',
-        queryset=Platform.objects.all(),
-        to_field_name='slug',
-        label='Platform (slug)',
+    is_full_depth = django_filters.BooleanFilter(
+        name='device_type__is_full_depth',
+        label='Is full depth',
     )
     )
     is_console_server = django_filters.BooleanFilter(
     is_console_server = django_filters.BooleanFilter(
         name='device_type__is_console_server',
         name='device_type__is_console_server',
@@ -387,13 +428,14 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         name='device_type__is_network_device',
         name='device_type__is_network_device',
         label='Is a network device',
         label='Is a network device',
     )
     )
+    mac_address = django_filters.CharFilter(
+        method='_mac_address',
+        label='MAC address',
+    )
     has_primary_ip = django_filters.BooleanFilter(
     has_primary_ip = django_filters.BooleanFilter(
         method='_has_primary_ip',
         method='_has_primary_ip',
         label='Has a primary IP',
         label='Has a primary IP',
     )
     )
-    status = django_filters.MultipleChoiceFilter(
-        choices=STATUS_CHOICES
-    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -433,13 +475,11 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 
 
 class DeviceComponentFilterSet(django_filters.FilterSet):
 class DeviceComponentFilterSet(django_filters.FilterSet):
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
+    device_id = django_filters.ModelChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
     )
     )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='device__name',
+    device = django_filters.ModelChoiceFilter(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
@@ -474,7 +514,21 @@ class PowerOutletFilter(DeviceComponentFilterSet):
         fields = ['name']
         fields = ['name']
 
 
 
 
-class InterfaceFilter(DeviceComponentFilterSet):
+class InterfaceFilter(django_filters.FilterSet):
+    """
+    Not using DeviceComponentFilterSet for Interfaces because we need to glean the ordering logic from the parent
+    Device's DeviceType.
+    """
+    device = django_filters.CharFilter(
+        method='filter_device',
+        name='name',
+        label='Device',
+    )
+    device_id = django_filters.NumberFilter(
+        method='filter_device',
+        name='pk',
+        label='Device (ID)',
+    )
     type = django_filters.CharFilter(
     type = django_filters.CharFilter(
         method='filter_type',
         method='filter_type',
         label='Interface type',
         label='Interface type',
@@ -491,17 +545,24 @@ class InterfaceFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['name', 'form_factor']
+        fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
+
+    def filter_device(self, queryset, name, value):
+        try:
+            device = Device.objects.select_related('device_type').get(**{name: value})
+            ordering = device.device_type.interface_ordering
+            return queryset.filter(device=device).order_naturally(ordering)
+        except Device.DoesNotExist:
+            return queryset.none()
 
 
     def filter_type(self, queryset, name, value):
     def filter_type(self, queryset, name, value):
         value = value.strip().lower()
         value = value.strip().lower()
-        if value == 'physical':
-            return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
-        elif value == 'virtual':
-            return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
-        elif value == 'lag':
-            return queryset.filter(form_factor=IFACE_FF_LAG)
-        return queryset
+        return {
+            'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES),
+            'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES),
+            'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES),
+            'lag': queryset.filter(form_factor=IFACE_FF_LAG),
+        }.get(value, queryset.none())
 
 
     def _mac_address(self, queryset, name, value):
     def _mac_address(self, queryset, name, value):
         value = value.strip()
         value = value.strip()
@@ -521,10 +582,24 @@ class DeviceBayFilter(DeviceComponentFilterSet):
 
 
 
 
 class InventoryItemFilter(DeviceComponentFilterSet):
 class InventoryItemFilter(DeviceComponentFilterSet):
+    parent_id = NullableModelMultipleChoiceFilter(
+        queryset=InventoryItem.objects.all(),
+        label='Parent inventory item (ID)',
+    )
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
+    )
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        name='manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
+        to_field_name='slug',
+        label='Manufacturer (slug)',
+    )
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['name']
+        fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
 
 
 
 
 class ConsoleConnectionFilter(django_filters.FilterSet):
 class ConsoleConnectionFilter(django_filters.FilterSet):

+ 20 - 14
netbox/dcim/forms.py

@@ -24,7 +24,7 @@ from .models import (
     IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer,
     IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES,
     InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES,
     RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
     RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
-    Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
+    Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
 )
 )
 
 
 
 
@@ -558,7 +558,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['name', 'slug', 'rpc_client']
+        fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
 
 
 
 
 #
 #
@@ -632,7 +632,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
         # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
         # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
         if instance and hasattr(instance, 'device_type'):
         if instance and hasattr(instance, 'device_type'):
-            initial = kwargs.get('initial', {})
+            initial = kwargs.get('initial', {}).copy()
             initial['manufacturer'] = instance.device_type.manufacturer
             initial['manufacturer'] = instance.device_type.manufacturer
             kwargs['initial'] = initial
             kwargs['initial'] = initial
 
 
@@ -1449,7 +1449,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
+        fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description']
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
@@ -1471,12 +1471,19 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 class InterfaceCreateForm(DeviceComponentForm):
 class InterfaceCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
+    enabled = forms.BooleanField(required=False)
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
+    mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
+
+        # Set interfaces enabled by default
+        kwargs['initial'] = kwargs.get('initial', {}).copy()
+        kwargs['initial'].update({'enabled': True})
+
         super(InterfaceCreateForm, self).__init__(*args, **kwargs)
         super(InterfaceCreateForm, self).__init__(*args, **kwargs)
 
 
         # Limit LAG choices to interfaces belonging to this device
         # Limit LAG choices to interfaces belonging to this device
@@ -1491,13 +1498,15 @@ class InterfaceCreateForm(DeviceComponentForm):
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
-    lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
+    enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
+    lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
+    mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['lag', 'description']
+        nullable_fields = ['lag', 'mtu', 'description']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1576,7 +1585,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         )
         )
     )
     )
     interface_b = ChainedModelChoiceField(
     interface_b = ChainedModelChoiceField(
-        queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
+        queryset=Interface.objects.connectable().select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
             'circuit_termination', 'connected_as_a', 'connected_as_b'
         ),
         ),
         chains=(
         chains=(
@@ -1585,7 +1594,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         label='Interface',
         label='Interface',
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
             api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
-            disabled_indicator='connection'
+            disabled_indicator='is_connected'
         )
         )
     )
     )
 
 
@@ -1598,9 +1607,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
 
 
         # Initialize interface A choices
         # Initialize interface A choices
-        device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude(
-            form_factor__in=VIRTUAL_IFACE_TYPES
-        ).select_related(
+        device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related(
             'circuit_termination', 'connected_as_a', 'connected_as_b'
             'circuit_termination', 'connected_as_a', 'connected_as_b'
         )
         )
         self.fields['interface_a'].choices = [
         self.fields['interface_a'].choices = [
@@ -1689,8 +1696,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
         return interface
         return interface
 
 
 
 
-class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):
-    confirm = forms.BooleanField(required=True)
+class InterfaceConnectionDeletionForm(ConfirmationForm):
     # Used for HTTP redirect upon successful deletion
     # Used for HTTP redirect upon successful deletion
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
 
 
@@ -1760,4 +1766,4 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['name', 'manufacturer', 'part_id', 'serial']
+        fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']

+ 25 - 0
netbox/dcim/migrations/0038_wireless_interfaces.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.1 on 2017-06-16 21:38
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0037_unicode_literals'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='interface',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='form_factor',
+            field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
+        ),
+    ]

+ 25 - 0
netbox/dcim/migrations/0039_interface_add_enabled_mtu.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.1 on 2017-06-23 17:05
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0038_wireless_interfaces'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='enabled',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='mtu',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'),
+        ),
+    ]

+ 26 - 0
netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-06-23 20:44
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0039_interface_add_enabled_mtu'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='asset_tag',
+            field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+    ]

+ 40 - 0
netbox/dcim/migrations/0041_napalm_integration.py

@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-07-14 17:26
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+def rpc_client_to_napalm_driver(apps, schema_editor):
+    """
+    Migrate legacy RPC clients to their respective NAPALM drivers
+    """
+    Platform = apps.get_model('dcim', 'Platform')
+
+    Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos')
+    Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0040_inventoryitem_add_asset_tag_description'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='device',
+            options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+        ),
+        migrations.AddField(
+            model_name='platform',
+            name='napalm_driver',
+            field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices.', max_length=50, verbose_name='NAPALM driver'),
+        ),
+        migrations.AlterField(
+            model_name='platform',
+            name='rpc_client',
+            field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'),
+        ),
+        migrations.RunPython(rpc_client_to_napalm_driver),
+    ]

+ 52 - 212
netbox/dcim/models.py

@@ -24,204 +24,10 @@ from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 from utilities.utils import csv_format
 from utilities.utils import csv_format
+from .constants import *
 from .fields import ASNField, MACAddressField
 from .fields import ASNField, MACAddressField
 
 
 
 
-RACK_TYPE_2POST = 100
-RACK_TYPE_4POST = 200
-RACK_TYPE_CABINET = 300
-RACK_TYPE_WALLFRAME = 1000
-RACK_TYPE_WALLCABINET = 1100
-RACK_TYPE_CHOICES = (
-    (RACK_TYPE_2POST, '2-post frame'),
-    (RACK_TYPE_4POST, '4-post frame'),
-    (RACK_TYPE_CABINET, '4-post cabinet'),
-    (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
-    (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
-)
-
-RACK_WIDTH_19IN = 19
-RACK_WIDTH_23IN = 23
-RACK_WIDTH_CHOICES = (
-    (RACK_WIDTH_19IN, '19 inches'),
-    (RACK_WIDTH_23IN, '23 inches'),
-)
-
-RACK_FACE_FRONT = 0
-RACK_FACE_REAR = 1
-RACK_FACE_CHOICES = [
-    [RACK_FACE_FRONT, 'Front'],
-    [RACK_FACE_REAR, 'Rear'],
-]
-
-SUBDEVICE_ROLE_PARENT = True
-SUBDEVICE_ROLE_CHILD = False
-SUBDEVICE_ROLE_CHOICES = (
-    (None, 'None'),
-    (SUBDEVICE_ROLE_PARENT, 'Parent'),
-    (SUBDEVICE_ROLE_CHILD, 'Child'),
-)
-
-IFACE_ORDERING_POSITION = 1
-IFACE_ORDERING_NAME = 2
-IFACE_ORDERING_CHOICES = [
-    [IFACE_ORDERING_POSITION, 'Slot/position'],
-    [IFACE_ORDERING_NAME, 'Name (alphabetically)']
-]
-
-# Virtual
-IFACE_FF_VIRTUAL = 0
-IFACE_FF_LAG = 200
-# Ethernet
-IFACE_FF_100ME_FIXED = 800
-IFACE_FF_1GE_FIXED = 1000
-IFACE_FF_1GE_GBIC = 1050
-IFACE_FF_1GE_SFP = 1100
-IFACE_FF_10GE_FIXED = 1150
-IFACE_FF_10GE_SFP_PLUS = 1200
-IFACE_FF_10GE_XFP = 1300
-IFACE_FF_10GE_XENPAK = 1310
-IFACE_FF_10GE_X2 = 1320
-IFACE_FF_25GE_SFP28 = 1350
-IFACE_FF_40GE_QSFP_PLUS = 1400
-IFACE_FF_100GE_CFP = 1500
-IFACE_FF_100GE_QSFP28 = 1600
-# Fibrechannel
-IFACE_FF_1GFC_SFP = 3010
-IFACE_FF_2GFC_SFP = 3020
-IFACE_FF_4GFC_SFP = 3040
-IFACE_FF_8GFC_SFP_PLUS = 3080
-IFACE_FF_16GFC_SFP_PLUS = 3160
-# Serial
-IFACE_FF_T1 = 4000
-IFACE_FF_E1 = 4010
-IFACE_FF_T3 = 4040
-IFACE_FF_E3 = 4050
-# Stacking
-IFACE_FF_STACKWISE = 5000
-IFACE_FF_STACKWISE_PLUS = 5050
-IFACE_FF_FLEXSTACK = 5100
-IFACE_FF_FLEXSTACK_PLUS = 5150
-IFACE_FF_JUNIPER_VCP = 5200
-# Other
-IFACE_FF_OTHER = 32767
-
-IFACE_FF_CHOICES = [
-    [
-        'Virtual interfaces',
-        [
-            [IFACE_FF_VIRTUAL, 'Virtual'],
-            [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
-        ]
-    ],
-    [
-        'Ethernet (fixed)',
-        [
-            [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
-            [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
-            [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
-        ]
-    ],
-    [
-        'Ethernet (modular)',
-        [
-            [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
-            [IFACE_FF_1GE_SFP, 'SFP (1GE)'],
-            [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
-            [IFACE_FF_10GE_XFP, 'XFP (10GE)'],
-            [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
-            [IFACE_FF_10GE_X2, 'X2 (10GE)'],
-            [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
-            [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
-            [IFACE_FF_100GE_CFP, 'CFP (100GE)'],
-            [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
-        ]
-    ],
-    [
-        'FibreChannel',
-        [
-            [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
-            [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
-            [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
-            [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
-            [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
-        ]
-    ],
-    [
-        'Serial',
-        [
-            [IFACE_FF_T1, 'T1 (1.544 Mbps)'],
-            [IFACE_FF_E1, 'E1 (2.048 Mbps)'],
-            [IFACE_FF_T3, 'T3 (45 Mbps)'],
-            [IFACE_FF_E3, 'E3 (34 Mbps)'],
-            [IFACE_FF_E3, 'E3 (34 Mbps)'],
-        ]
-    ],
-    [
-        'Stacking',
-        [
-            [IFACE_FF_STACKWISE, 'Cisco StackWise'],
-            [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
-            [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
-            [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
-            [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
-        ]
-    ],
-    [
-        'Other',
-        [
-            [IFACE_FF_OTHER, 'Other'],
-        ]
-    ],
-]
-
-VIRTUAL_IFACE_TYPES = [
-    IFACE_FF_VIRTUAL,
-    IFACE_FF_LAG,
-]
-
-STATUS_OFFLINE = 0
-STATUS_ACTIVE = 1
-STATUS_PLANNED = 2
-STATUS_STAGED = 3
-STATUS_FAILED = 4
-STATUS_INVENTORY = 5
-STATUS_CHOICES = [
-    [STATUS_ACTIVE, 'Active'],
-    [STATUS_OFFLINE, 'Offline'],
-    [STATUS_PLANNED, 'Planned'],
-    [STATUS_STAGED, 'Staged'],
-    [STATUS_FAILED, 'Failed'],
-    [STATUS_INVENTORY, 'Inventory'],
-]
-
-DEVICE_STATUS_CLASSES = {
-    0: 'warning',
-    1: 'success',
-    2: 'info',
-    3: 'primary',
-    4: 'danger',
-    5: 'default',
-}
-
-CONNECTION_STATUS_PLANNED = False
-CONNECTION_STATUS_CONNECTED = True
-CONNECTION_STATUS_CHOICES = [
-    [CONNECTION_STATUS_PLANNED, 'Planned'],
-    [CONNECTION_STATUS_CONNECTED, 'Connected'],
-]
-
-# For mapping platform -> NC client
-RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos'
-RPC_CLIENT_CISCO_IOS = 'cisco-ios'
-RPC_CLIENT_OPENGEAR = 'opengear'
-RPC_CLIENT_CHOICES = [
-    [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'],
-    [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'],
-    [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'],
-]
-
-
 #
 #
 # Regions
 # Regions
 #
 #
@@ -816,7 +622,7 @@ class PowerOutletTemplate(models.Model):
         return self.name
         return self.name
 
 
 
 
-class InterfaceManager(models.Manager):
+class InterfaceQuerySet(models.QuerySet):
 
 
     def order_naturally(self, method=IFACE_ORDERING_POSITION):
     def order_naturally(self, method=IFACE_ORDERING_POSITION):
         """
         """
@@ -841,13 +647,12 @@ class InterfaceManager(models.Manager):
         The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
         The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
         the prescribed fields.
         the prescribed fields.
         """
         """
-        queryset = self.get_queryset()
-        sql_col = '{}.name'.format(queryset.model._meta.db_table)
+        sql_col = '{}.name'.format(self.model._meta.db_table)
         ordering = {
         ordering = {
             IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
             IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
             IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
             IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
         }[method]
         }[method]
-        return queryset.extra(select={
+        return self.extra(select={
             '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
             '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
             '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
             '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
             '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
             '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
@@ -856,6 +661,13 @@ class InterfaceManager(models.Manager):
             '_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
             '_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
         }).order_by(*ordering)
         }).order_by(*ordering)
 
 
+    def connectable(self):
+        """
+        Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
+        wireless).
+        """
+        return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class InterfaceTemplate(models.Model):
 class InterfaceTemplate(models.Model):
@@ -867,7 +679,7 @@ class InterfaceTemplate(models.Model):
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
     mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
 
 
-    objects = InterfaceManager()
+    objects = InterfaceQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
         ordering = ['device_type', 'name']
         ordering = ['device_type', 'name']
@@ -926,7 +738,10 @@ class Platform(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)
-    rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client')
+    napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
+                                     help_text="The name of the NAPALM driver to use when interacting with devices.")
+    rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
+                                  verbose_name='Legacy RPC client')
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -997,6 +812,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
         unique_together = ['rack', 'position', 'face']
         unique_together = ['rack', 'position', 'face']
+        permissions = (
+            ('napalm_read', 'Read-only access to devices via NAPALM'),
+            ('napalm_write', 'Read/write access to devices via NAPALM'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return self.display_name or super(Device, self).__str__()
         return self.display_name or super(Device, self).__str__()
@@ -1308,16 +1127,27 @@ class Interface(models.Model):
     of an InterfaceConnection.
     of an InterfaceConnection.
     """
     """
     device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
     device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
-    lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL,
-                            verbose_name='Parent LAG')
+    lag = models.ForeignKey(
+        'self',
+        models.SET_NULL,
+        related_name='member_interfaces',
+        null=True,
+        blank=True,
+        verbose_name='Parent LAG'
+    )
     name = models.CharField(max_length=30)
     name = models.CharField(max_length=30)
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
+    enabled = models.BooleanField(default=True)
     mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
     mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
-    mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
-                                    help_text="This interface is used only for out-of-band management")
+    mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU')
+    mgmt_only = models.BooleanField(
+        default=False,
+        verbose_name='OOB Management',
+        help_text="This interface is used only for out-of-band management"
+    )
     description = models.CharField(max_length=100, blank=True)
     description = models.CharField(max_length=100, blank=True)
 
 
-    objects = InterfaceManager()
+    objects = InterfaceQuerySet.as_manager()
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -1329,10 +1159,10 @@ class Interface(models.Model):
     def clean(self):
     def clean(self):
 
 
         # Virtual interfaces cannot be connected
         # Virtual interfaces cannot be connected
-        if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected:
+        if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
             raise ValidationError({
             raise ValidationError({
-                'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
-                               "interface or choose a physical form factor."
+                'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
+                               "Disconnect the interface or choose a suitable form factor."
             })
             })
 
 
         # An interface's LAG must belong to the same device
         # An interface's LAG must belong to the same device
@@ -1344,7 +1174,7 @@ class Interface(models.Model):
             })
             })
 
 
         # A virtual interface cannot have a parent LAG
         # A virtual interface cannot have a parent LAG
-        if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
+        if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
             raise ValidationError({
             raise ValidationError({
                 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
                 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
             })
             })
@@ -1361,6 +1191,10 @@ class Interface(models.Model):
     def is_virtual(self):
     def is_virtual(self):
         return self.form_factor in VIRTUAL_IFACE_TYPES
         return self.form_factor in VIRTUAL_IFACE_TYPES
 
 
+    @property
+    def is_wireless(self):
+        return self.form_factor in WIRELESS_IFACE_TYPES
+
     @property
     @property
     def is_lag(self):
     def is_lag(self):
         return self.form_factor == IFACE_FF_LAG
         return self.form_factor == IFACE_FF_LAG
@@ -1479,11 +1313,17 @@ class InventoryItem(models.Model):
     device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
     device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
     parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
     parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
     name = models.CharField(max_length=50, verbose_name='Name')
     name = models.CharField(max_length=50, verbose_name='Name')
-    manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True,
-                                     on_delete=models.PROTECT)
+    manufacturer = models.ForeignKey(
+        'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True
+    )
     part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
     part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
     serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
     serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
+    asset_tag = NullableCharField(
+        max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
+        help_text='A unique tag used to identify this item'
+    )
     discovered = models.BooleanField(default=False, verbose_name='Discovered')
     discovered = models.BooleanField(default=False, verbose_name='Discovered')
+    description = models.CharField(max_length=100, blank=True)
 
 
     class Meta:
     class Meta:
         ordering = ['device__id', 'parent__id', 'name']
         ordering = ['device__id', 'parent__id', 'name']

+ 75 - 71
netbox/dcim/tables.py

@@ -3,11 +3,11 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from utilities.tables import BaseTable, SearchTable, ToggleColumn
+from utilities.tables import BaseTable, ToggleColumn
 from .models import (
 from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
-    Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, Region, Site,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
+    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
 )
 )
 
 
 
 
@@ -142,30 +142,26 @@ class SiteTable(BaseTable):
     name = tables.LinkColumn()
     name = tables.LinkColumn()
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(BaseTable.Meta):
+        model = Site
+        fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
+
+
+class SiteDetailTable(SiteTable):
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
     vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
     vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
 
 
-    class Meta(BaseTable.Meta):
-        model = Site
+    class Meta(SiteTable.Meta):
         fields = (
         fields = (
             'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
             'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
             'vlan_count', 'circuit_count',
             'vlan_count', 'circuit_count',
         )
         )
 
 
 
 
-class SiteSearchTable(SearchTable):
-    name = tables.LinkColumn()
-    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-
-    class Meta(SearchTable.Meta):
-        model = Site
-        fields = ('name', 'facility', 'region', 'tenant', 'asn')
-
-
 #
 #
 # Rack groups
 # Rack groups
 #
 #
@@ -214,27 +210,20 @@ class RackTable(BaseTable):
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     role = tables.TemplateColumn(RACK_ROLE)
     role = tables.TemplateColumn(RACK_ROLE)
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
-    devices = tables.Column(accessor=Accessor('device_count'))
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         model = Rack
-        fields = (
-            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
-        )
+        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height')
 
 
 
 
-class RackSearchTable(SearchTable):
-    name = tables.LinkColumn()
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-    role = tables.TemplateColumn(RACK_ROLE)
-    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
+class RackDetailTable(RackTable):
+    devices = tables.Column(accessor=Accessor('device_count'))
+    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
-    class Meta(SearchTable.Meta):
-        model = Rack
-        fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height')
+    class Meta(RackTable.Meta):
+        fields = (
+            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
+        )
 
 
 
 
 class RackImportTable(BaseTable):
 class RackImportTable(BaseTable):
@@ -302,23 +291,7 @@ class DeviceTypeTable(BaseTable):
         model = DeviceType
         model = DeviceType
         fields = (
         fields = (
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-            'is_network_device', 'subdevice_role', 'instance_count'
-        )
-
-
-class DeviceTypeSearchTable(SearchTable):
-    model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
-    is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
-    is_console_server = tables.BooleanColumn(verbose_name='CS')
-    is_pdu = tables.BooleanColumn(verbose_name='PDU')
-    is_network_device = tables.BooleanColumn(verbose_name='Net')
-    subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
-
-    class Meta(SearchTable.Meta):
-        model = DeviceType
-        fields = (
-            'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
-            'is_network_device', 'subdevice_role',
+            'is_network_device', 'subdevice_role', 'instance_count',
         )
         )
 
 
 
 
@@ -333,7 +306,6 @@ class ConsolePortTemplateTable(BaseTable):
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = ('pk', 'name')
         fields = ('pk', 'name')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 class ConsoleServerPortTemplateTable(BaseTable):
 class ConsoleServerPortTemplateTable(BaseTable):
@@ -343,7 +315,6 @@ class ConsoleServerPortTemplateTable(BaseTable):
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = ('pk', 'name')
         fields = ('pk', 'name')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 class PowerPortTemplateTable(BaseTable):
 class PowerPortTemplateTable(BaseTable):
@@ -353,7 +324,6 @@ class PowerPortTemplateTable(BaseTable):
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = ('pk', 'name')
         fields = ('pk', 'name')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 class PowerOutletTemplateTable(BaseTable):
 class PowerOutletTemplateTable(BaseTable):
@@ -363,17 +333,16 @@ class PowerOutletTemplateTable(BaseTable):
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = ('pk', 'name')
         fields = ('pk', 'name')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 class InterfaceTemplateTable(BaseTable):
 class InterfaceTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}")
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ('pk', 'name', 'form_factor')
+        fields = ('pk', 'name', 'mgmt_only', 'form_factor')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 class DeviceBayTemplateTable(BaseTable):
 class DeviceBayTemplateTable(BaseTable):
@@ -383,7 +352,6 @@ class DeviceBayTemplateTable(BaseTable):
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = ('pk', 'name')
         fields = ('pk', 'name')
         empty_text = "None"
         empty_text = "None"
-        show_header = False
 
 
 
 
 #
 #
@@ -438,30 +406,20 @@ class DeviceTable(BaseTable):
         'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
         'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
         text=lambda record: record.device_type.full_name
         text=lambda record: record.device_type.full_name
     )
     )
-    primary_ip = tables.TemplateColumn(
-        orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
-    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
-        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
+        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
 
 
 
 
-class DeviceSearchTable(SearchTable):
-    name = tables.TemplateColumn(template_code=DEVICE_LINK)
-    status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
-    device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
-    device_type = tables.LinkColumn(
-        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
-        text=lambda record: record.device_type.full_name
+class DeviceDetailTable(DeviceTable):
+    primary_ip = tables.TemplateColumn(
+        orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
     )
     )
 
 
-    class Meta(SearchTable.Meta):
+    class Meta(DeviceTable.Meta):
         model = Device
         model = Device
-        fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
+        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
 
 
 
 
 class DeviceImportTable(BaseTable):
 class DeviceImportTable(BaseTable):
@@ -480,6 +438,52 @@ class DeviceImportTable(BaseTable):
         empty_text = False
         empty_text = False
 
 
 
 
+#
+# Device components
+#
+
+class ConsolePortTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = ConsolePort
+        fields = ('name',)
+
+
+class ConsoleServerPortTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = ConsoleServerPort
+        fields = ('name',)
+
+
+class PowerPortTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = PowerPort
+        fields = ('name',)
+
+
+class PowerOutletTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = PowerOutlet
+        fields = ('name',)
+
+
+class InterfaceTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
+
+
+class DeviceBayTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = DeviceBay
+        fields = ('name',)
+
+
 #
 #
 # Device connections
 # Device connections
 #
 #

+ 2 - 0
netbox/dcim/urls.py

@@ -122,7 +122,9 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
+    url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
+    url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),

+ 88 - 34
netbox/dcim/views.py

@@ -205,6 +205,8 @@ class RegionEditView(RegionCreateView):
 class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_region'
     permission_required = 'dcim.delete_region'
     cls = Region
     cls = Region
+    queryset = Region.objects.annotate(site_count=Count('sites'))
+    table = tables.RegionTable
     default_return_url = 'dcim:region_list'
     default_return_url = 'dcim:region_list'
 
 
 
 
@@ -216,7 +218,7 @@ class SiteListView(ObjectListView):
     queryset = Site.objects.select_related('region', 'tenant')
     queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
-    table = tables.SiteTable
+    table = tables.SiteDetailTable
     template_name = 'dcim/site_list.html'
     template_name = 'dcim/site_list.html'
 
 
 
 
@@ -273,9 +275,10 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_site'
     permission_required = 'dcim.change_site'
     cls = Site
     cls = Site
+    queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
+    table = tables.SiteTable
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm
-    template_name = 'dcim/site_bulk_edit.html'
     default_return_url = 'dcim:site_list'
     default_return_url = 'dcim:site_list'
 
 
 
 
@@ -307,7 +310,9 @@ class RackGroupEditView(RackGroupCreateView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     permission_required = 'dcim.delete_rackgroup'
     cls = RackGroup
     cls = RackGroup
+    queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter = filters.RackGroupFilter
+    table = tables.RackGroupTable
     default_return_url = 'dcim:rackgroup_list'
     default_return_url = 'dcim:rackgroup_list'
 
 
 
 
@@ -337,6 +342,8 @@ class RackRoleEditView(RackRoleCreateView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackrole'
     permission_required = 'dcim.delete_rackrole'
     cls = RackRole
     cls = RackRole
+    queryset = RackRole.objects.annotate(rack_count=Count('racks'))
+    table = tables.RackRoleTable
     default_return_url = 'dcim:rackrole_list'
     default_return_url = 'dcim:rackrole_list'
 
 
 
 
@@ -354,7 +361,7 @@ class RackListView(ObjectListView):
     )
     )
     filter = filters.RackFilter
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     filter_form = forms.RackFilterForm
-    table = tables.RackTable
+    table = tables.RackDetailTable
     template_name = 'dcim/rack_list.html'
     template_name = 'dcim/rack_list.html'
 
 
 
 
@@ -455,16 +462,19 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rack'
     permission_required = 'dcim.change_rack'
     cls = Rack
     cls = Rack
+    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
     filter = filters.RackFilter
     filter = filters.RackFilter
+    table = tables.RackTable
     form = forms.RackBulkEditForm
     form = forms.RackBulkEditForm
-    template_name = 'dcim/rack_bulk_edit.html'
     default_return_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
+    queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
     filter = filters.RackFilter
     filter = filters.RackFilter
+    table = tables.RackTable
     default_return_url = 'dcim:rack_list'
     default_return_url = 'dcim:rack_list'
 
 
 
 
@@ -510,6 +520,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     permission_required = 'dcim.delete_rackreservation'
     cls = RackReservation
     cls = RackReservation
+    table = tables.RackReservationTable
     default_return_url = 'dcim:rackreservation_list'
     default_return_url = 'dcim:rackreservation_list'
 
 
 
 
@@ -539,6 +550,8 @@ class ManufacturerEditView(ManufacturerCreateView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_manufacturer'
     permission_required = 'dcim.delete_manufacturer'
     cls = Manufacturer
     cls = Manufacturer
+    queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
+    table = tables.ManufacturerTable
     default_return_url = 'dcim:manufacturer_list'
     default_return_url = 'dcim:manufacturer_list'
 
 
 
 
@@ -562,36 +575,36 @@ class DeviceTypeView(View):
 
 
         # Component tables
         # Component tables
         consoleport_table = tables.ConsolePortTemplateTable(
         consoleport_table = tables.ConsolePortTemplateTable(
-            natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+            natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            show_header=False
         )
         )
         consoleserverport_table = tables.ConsoleServerPortTemplateTable(
         consoleserverport_table = tables.ConsoleServerPortTemplateTable(
-            natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+            natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            show_header=False
         )
         )
         powerport_table = tables.PowerPortTemplateTable(
         powerport_table = tables.PowerPortTemplateTable(
-            natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+            natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            show_header=False
         )
         )
         poweroutlet_table = tables.PowerOutletTemplateTable(
         poweroutlet_table = tables.PowerOutletTemplateTable(
-            natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
-        )
-        mgmt_interface_table = tables.InterfaceTemplateTable(
-            list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
-                device_type=devicetype, mgmt_only=True
-            ))
+            natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            show_header=False
         )
         )
         interface_table = tables.InterfaceTemplateTable(
         interface_table = tables.InterfaceTemplateTable(
-            list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
-                device_type=devicetype, mgmt_only=False
-            ))
+            list(InterfaceTemplate.objects.order_naturally(
+                devicetype.interface_ordering
+            ).filter(device_type=devicetype)),
+            show_header=False
         )
         )
         devicebay_table = tables.DeviceBayTemplateTable(
         devicebay_table = tables.DeviceBayTemplateTable(
-            natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
+            natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')),
+            show_header=False
         )
         )
         if request.user.has_perm('dcim.change_devicetype'):
         if request.user.has_perm('dcim.change_devicetype'):
             consoleport_table.base_columns['pk'].visible = True
             consoleport_table.base_columns['pk'].visible = True
             consoleserverport_table.base_columns['pk'].visible = True
             consoleserverport_table.base_columns['pk'].visible = True
             powerport_table.base_columns['pk'].visible = True
             powerport_table.base_columns['pk'].visible = True
             poweroutlet_table.base_columns['pk'].visible = True
             poweroutlet_table.base_columns['pk'].visible = True
-            mgmt_interface_table.base_columns['pk'].visible = True
             interface_table.base_columns['pk'].visible = True
             interface_table.base_columns['pk'].visible = True
             devicebay_table.base_columns['pk'].visible = True
             devicebay_table.base_columns['pk'].visible = True
 
 
@@ -601,7 +614,6 @@ class DeviceTypeView(View):
             'consoleserverport_table': consoleserverport_table,
             'consoleserverport_table': consoleserverport_table,
             'powerport_table': powerport_table,
             'powerport_table': powerport_table,
             'poweroutlet_table': poweroutlet_table,
             'poweroutlet_table': poweroutlet_table,
-            'mgmt_interface_table': mgmt_interface_table,
             'interface_table': interface_table,
             'interface_table': interface_table,
             'devicebay_table': devicebay_table,
             'devicebay_table': devicebay_table,
         })
         })
@@ -628,16 +640,19 @@ 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
+    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
+    table = tables.DeviceTypeTable
     form = forms.DeviceTypeBulkEditForm
     form = forms.DeviceTypeBulkEditForm
-    template_name = 'dcim/devicetype_bulk_edit.html'
     default_return_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
+    queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
+    table = tables.DeviceTypeTable
     default_return_url = 'dcim:devicetype_list'
     default_return_url = 'dcim:devicetype_list'
 
 
 
 
@@ -660,6 +675,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_field = 'device_type'
     parent_field = 'device_type'
     cls = ConsolePortTemplate
     cls = ConsolePortTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.ConsolePortTemplateTable
 
 
 
 
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -675,6 +691,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
     permission_required = 'dcim.delete_consoleserverporttemplate'
     permission_required = 'dcim.delete_consoleserverporttemplate'
     cls = ConsoleServerPortTemplate
     cls = ConsoleServerPortTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.ConsoleServerPortTemplateTable
 
 
 
 
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -690,6 +707,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerporttemplate'
     permission_required = 'dcim.delete_powerporttemplate'
     cls = PowerPortTemplate
     cls = PowerPortTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.PowerPortTemplateTable
 
 
 
 
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -705,6 +723,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     permission_required = 'dcim.delete_poweroutlettemplate'
     permission_required = 'dcim.delete_poweroutlettemplate'
     cls = PowerOutletTemplate
     cls = PowerOutletTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.PowerOutletTemplateTable
 
 
 
 
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -720,14 +739,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interfacetemplate'
     permission_required = 'dcim.change_interfacetemplate'
     cls = InterfaceTemplate
     cls = InterfaceTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.InterfaceTemplateTable
     form = forms.InterfaceTemplateBulkEditForm
     form = forms.InterfaceTemplateBulkEditForm
-    template_name = 'dcim/interfacetemplate_bulk_edit.html'
 
 
 
 
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interfacetemplate'
     permission_required = 'dcim.delete_interfacetemplate'
     cls = InterfaceTemplate
     cls = InterfaceTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.InterfaceTemplateTable
 
 
 
 
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -743,6 +763,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebaytemplate'
     permission_required = 'dcim.delete_devicebaytemplate'
     cls = DeviceBayTemplate
     cls = DeviceBayTemplate
     parent_cls = DeviceType
     parent_cls = DeviceType
+    table = tables.DeviceBayTemplateTable
 
 
 
 
 #
 #
@@ -771,6 +792,8 @@ class DeviceRoleEditView(DeviceRoleCreateView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicerole'
     permission_required = 'dcim.delete_devicerole'
     cls = DeviceRole
     cls = DeviceRole
+    queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
+    table = tables.DeviceRoleTable
     default_return_url = 'dcim:devicerole_list'
     default_return_url = 'dcim:devicerole_list'
 
 
 
 
@@ -800,6 +823,8 @@ class PlatformEditView(PlatformCreateView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_platform'
     permission_required = 'dcim.delete_platform'
     cls = Platform
     cls = Platform
+    queryset = Platform.objects.annotate(device_count=Count('devices'))
+    table = tables.PlatformTable
     default_return_url = 'dcim:platform_list'
     default_return_url = 'dcim:platform_list'
 
 
 
 
@@ -812,7 +837,7 @@ class DeviceListView(ObjectListView):
                                              'primary_ip4', 'primary_ip6')
                                              'primary_ip4', 'primary_ip6')
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
     filter_form = forms.DeviceFilterForm
     filter_form = forms.DeviceFilterForm
-    table = tables.DeviceTable
+    table = tables.DeviceDetailTable
     template_name = 'dcim/device_list.html'
     template_name = 'dcim/device_list.html'
 
 
 
 
@@ -835,14 +860,10 @@ class DeviceView(View):
         power_outlets = natsorted(
         power_outlets = natsorted(
             PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
             PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
         )
         )
-        interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
-            device=device, mgmt_only=False
-        ).select_related(
-            'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-            'circuit_termination__circuit'
-        ).prefetch_related('ip_addresses')
-        mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
-            device=device, mgmt_only=True
+        interfaces = Interface.objects.order_naturally(
+            device.device_type.interface_ordering
+        ).filter(
+            device=device
         ).select_related(
         ).select_related(
             'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
             'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
             'circuit_termination__circuit'
             'circuit_termination__circuit'
@@ -873,7 +894,6 @@ class DeviceView(View):
             'power_ports': power_ports,
             'power_ports': power_ports,
             'power_outlets': power_outlets,
             'power_outlets': power_outlets,
             'interfaces': interfaces,
             'interfaces': interfaces,
-            'mgmt_interfaces': mgmt_interfaces,
             'device_bays': device_bays,
             'device_bays': device_bays,
             'services': services,
             'services': services,
             'secrets': secrets,
             'secrets': secrets,
@@ -901,7 +921,20 @@ class DeviceInventoryView(View):
         })
         })
 
 
 
 
-class DeviceLLDPNeighborsView(View):
+class DeviceStatusView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.napalm_read'
+
+    def get(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+
+        return render(request, 'dcim/device_status.html', {
+            'device': device,
+        })
+
+
+class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.napalm_read'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -920,6 +953,18 @@ class DeviceLLDPNeighborsView(View):
         })
         })
 
 
 
 
+class DeviceConfigView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.napalm_read'
+
+    def get(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+
+        return render(request, 'dcim/device_config.html', {
+            'device': device,
+        })
+
+
 class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
 class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.add_device'
     permission_required = 'dcim.add_device'
     model = Device
     model = Device
@@ -968,16 +1013,19 @@ 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
+    queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
+    table = tables.DeviceTable
     form = forms.DeviceBulkEditForm
     form = forms.DeviceBulkEditForm
-    template_name = 'dcim/device_bulk_edit.html'
     default_return_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
+    queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
     filter = filters.DeviceFilter
     filter = filters.DeviceFilter
+    table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
 
 
@@ -1085,6 +1133,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     cls = ConsolePort
     cls = ConsolePort
     parent_cls = Device
     parent_cls = Device
+    table = tables.ConsolePortTable
 
 
 
 
 class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -1210,6 +1259,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     cls = ConsoleServerPort
     cls = ConsoleServerPort
     parent_cls = Device
     parent_cls = Device
+    table = tables.ConsoleServerPortTable
 
 
 
 
 #
 #
@@ -1316,6 +1366,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     cls = PowerPort
     cls = PowerPort
     parent_cls = Device
     parent_cls = Device
+    table = tables.PowerPortTable
 
 
 
 
 class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -1443,6 +1494,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     cls = PowerOutlet
     cls = PowerOutlet
     parent_cls = Device
     parent_cls = Device
+    table = tables.PowerOutletTable
 
 
 
 
 #
 #
@@ -1485,14 +1537,15 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     cls = Interface
     cls = Interface
     parent_cls = Device
     parent_cls = Device
+    table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
-    template_name = 'dcim/interface_bulk_edit.html'
 
 
 
 
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     cls = Interface
     cls = Interface
     parent_cls = Device
     parent_cls = Device
+    table = tables.InterfaceTable
 
 
 
 
 #
 #
@@ -1573,6 +1626,7 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     cls = DeviceBay
     cls = DeviceBay
     parent_cls = Device
     parent_cls = Device
+    table = tables.DeviceBayTable
 
 
 
 
 #
 #

+ 10 - 0
netbox/extras/api/customfields.py

@@ -111,6 +111,16 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
                 defaults={'serialized_value': custom_field.serialize_value(value)},
                 defaults={'serialized_value': custom_field.serialize_value(value)},
             )
             )
 
 
+    def validate(self, data):
+        """
+        Enforce model validation (see utilities.api.ModelValidationMixin)
+        """
+        model_data = data.copy()
+        model_data.pop('custom_fields', None)
+        instance = self.Meta.model(**model_data)
+        instance.clean()
+        return data
+
     def create(self, validated_data):
     def create(self, validated_data):
 
 
         custom_fields = validated_data.pop('custom_fields', None)
         custom_fields = validated_data.pop('custom_fields', None)

+ 5 - 2
netbox/extras/api/serializers.py

@@ -10,7 +10,7 @@ from extras.models import (
     ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
     ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
 )
 )
 from users.api.serializers import NestedUserSerializer
 from users.api.serializers import NestedUserSerializer
-from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer
+from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ModelValidationMixin
 
 
 
 
 #
 #
@@ -104,7 +104,7 @@ class ImageAttachmentSerializer(serializers.ModelSerializer):
         return serializer(obj.parent, context={'request': self.context['request']}).data
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 
 
 
-class WritableImageAttachmentSerializer(serializers.ModelSerializer):
+class WritableImageAttachmentSerializer(ModelValidationMixin, serializers.ModelSerializer):
     content_type = ContentTypeFieldSerializer()
     content_type = ContentTypeFieldSerializer()
 
 
     class Meta:
     class Meta:
@@ -121,6 +121,9 @@ class WritableImageAttachmentSerializer(serializers.ModelSerializer):
                 "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
                 "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
             )
             )
 
 
+        # Enforce model validation
+        super(WritableImageAttachmentSerializer, self).validate(data)
+
         return data
         return data
 
 
 
 

+ 62 - 0
netbox/extras/constants.py

@@ -0,0 +1,62 @@
+from __future__ import unicode_literals
+
+
+# Models which support custom fields
+CUSTOMFIELD_MODELS = (
+    'site', 'rack', 'devicetype', 'device',                 # DCIM
+    'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf',      # IPAM
+    'provider', 'circuit',                                  # Circuits
+    'tenant',                                               # Tenants
+)
+
+# Custom field types
+CF_TYPE_TEXT = 100
+CF_TYPE_INTEGER = 200
+CF_TYPE_BOOLEAN = 300
+CF_TYPE_DATE = 400
+CF_TYPE_URL = 500
+CF_TYPE_SELECT = 600
+CUSTOMFIELD_TYPE_CHOICES = (
+    (CF_TYPE_TEXT, 'Text'),
+    (CF_TYPE_INTEGER, 'Integer'),
+    (CF_TYPE_BOOLEAN, 'Boolean (true/false)'),
+    (CF_TYPE_DATE, 'Date'),
+    (CF_TYPE_URL, 'URL'),
+    (CF_TYPE_SELECT, 'Selection'),
+)
+
+# Graph types
+GRAPH_TYPE_INTERFACE = 100
+GRAPH_TYPE_PROVIDER = 200
+GRAPH_TYPE_SITE = 300
+GRAPH_TYPE_CHOICES = (
+    (GRAPH_TYPE_INTERFACE, 'Interface'),
+    (GRAPH_TYPE_PROVIDER, 'Provider'),
+    (GRAPH_TYPE_SITE, 'Site'),
+)
+
+# Models which support export templates
+EXPORTTEMPLATE_MODELS = [
+    'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',    # DCIM
+    'aggregate', 'prefix', 'ipaddress', 'vlan',                                     # IPAM
+    'provider', 'circuit',                                                          # Circuits
+    'tenant',                                                                       # Tenants
+]
+
+# User action types
+ACTION_CREATE = 1
+ACTION_IMPORT = 2
+ACTION_EDIT = 3
+ACTION_BULK_EDIT = 4
+ACTION_DELETE = 5
+ACTION_BULK_DELETE = 6
+ACTION_BULK_CREATE = 7
+ACTION_CHOICES = (
+    (ACTION_CREATE, 'created'),
+    (ACTION_BULK_CREATE, 'bulk created'),
+    (ACTION_IMPORT, 'imported'),
+    (ACTION_EDIT, 'modified'),
+    (ACTION_BULK_EDIT, 'bulk edited'),
+    (ACTION_DELETE, 'deleted'),
+    (ACTION_BULK_DELETE, 'bulk deleted'),
+)

+ 12 - 2
netbox/extras/management/commands/nbshell.py

@@ -37,19 +37,29 @@ class Command(BaseCommand):
     def get_namespace(self):
     def get_namespace(self):
         namespace = {}
         namespace = {}
 
 
-        # Gather Django models from each app
+        # Gather Django models and constants from each app
         for app in APPS:
         for app in APPS:
             self.django_models[app] = []
             self.django_models[app] = []
+
+            # Models
             app_models = sys.modules['{}.models'.format(app)]
             app_models = sys.modules['{}.models'.format(app)]
             for name in dir(app_models):
             for name in dir(app_models):
                 model = getattr(app_models, name)
                 model = getattr(app_models, name)
                 try:
                 try:
-                    if issubclass(model, Model):
+                    if issubclass(model, Model) and model._meta.app_label == app:
                         namespace[name] = model
                         namespace[name] = model
                         self.django_models[app].append(name)
                         self.django_models[app].append(name)
                 except TypeError:
                 except TypeError:
                     pass
                     pass
 
 
+            # Constants
+            try:
+                app_constants = sys.modules['{}.constants'.format(app)]
+                for name in dir(app_constants):
+                    namespace[name] = getattr(app_constants, name)
+            except KeyError:
+                pass
+
         # Load convenience commands
         # Load convenience commands
         namespace.update({
         namespace.update({
             'lsmodels': self._lsmodels,
             'lsmodels': self._lsmodels,

+ 1 - 56
netbox/extras/models.py

@@ -15,62 +15,7 @@ from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
-
-
-CUSTOMFIELD_MODELS = (
-    'site', 'rack', 'devicetype', 'device',                 # DCIM
-    'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf',      # IPAM
-    'provider', 'circuit',                                  # Circuits
-    'tenant',                                               # Tenants
-)
-
-CF_TYPE_TEXT = 100
-CF_TYPE_INTEGER = 200
-CF_TYPE_BOOLEAN = 300
-CF_TYPE_DATE = 400
-CF_TYPE_URL = 500
-CF_TYPE_SELECT = 600
-CUSTOMFIELD_TYPE_CHOICES = (
-    (CF_TYPE_TEXT, 'Text'),
-    (CF_TYPE_INTEGER, 'Integer'),
-    (CF_TYPE_BOOLEAN, 'Boolean (true/false)'),
-    (CF_TYPE_DATE, 'Date'),
-    (CF_TYPE_URL, 'URL'),
-    (CF_TYPE_SELECT, 'Selection'),
-)
-
-GRAPH_TYPE_INTERFACE = 100
-GRAPH_TYPE_PROVIDER = 200
-GRAPH_TYPE_SITE = 300
-GRAPH_TYPE_CHOICES = (
-    (GRAPH_TYPE_INTERFACE, 'Interface'),
-    (GRAPH_TYPE_PROVIDER, 'Provider'),
-    (GRAPH_TYPE_SITE, 'Site'),
-)
-
-EXPORTTEMPLATE_MODELS = [
-    'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',    # DCIM
-    'aggregate', 'prefix', 'ipaddress', 'vlan',                                     # IPAM
-    'provider', 'circuit',                                                          # Circuits
-    'tenant',                                                                       # Tenants
-]
-
-ACTION_CREATE = 1
-ACTION_IMPORT = 2
-ACTION_EDIT = 3
-ACTION_BULK_EDIT = 4
-ACTION_DELETE = 5
-ACTION_BULK_DELETE = 6
-ACTION_BULK_CREATE = 7
-ACTION_CHOICES = (
-    (ACTION_CREATE, 'created'),
-    (ACTION_BULK_CREATE, 'bulk created'),
-    (ACTION_IMPORT, 'imported'),
-    (ACTION_EDIT, 'modified'),
-    (ACTION_BULK_EDIT, 'bulk edited'),
-    (ACTION_DELETE, 'deleted'),
-    (ACTION_BULK_DELETE, 'bulk deleted'),
-)
+from .constants import *
 
 
 
 
 #
 #

+ 34 - 8
netbox/ipam/api/serializers.py

@@ -1,4 +1,5 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
+from collections import OrderedDict
 
 
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
@@ -6,11 +7,11 @@ from rest_framework.validators import UniqueTogetherValidator
 from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
 from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import (
 from ipam.models import (
-    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
-    Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
+    Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix,
+    PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
 )
 )
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer
+from utilities.api import ChoiceFieldSerializer, ModelValidationMixin
 
 
 
 
 #
 #
@@ -22,7 +23,7 @@ class VRFSerializer(CustomFieldModelSerializer):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
 
 
 
 
 class NestedVRFSerializer(serializers.ModelSerializer):
 class NestedVRFSerializer(serializers.ModelSerializer):
@@ -44,7 +45,7 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
 # Roles
 # Roles
 #
 #
 
 
-class RoleSerializer(serializers.ModelSerializer):
+class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
@@ -63,7 +64,7 @@ class NestedRoleSerializer(serializers.ModelSerializer):
 # RIRs
 # RIRs
 #
 #
 
 
-class RIRSerializer(serializers.ModelSerializer):
+class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
@@ -141,6 +142,9 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
                 validator.set_context(self)
                 validator.set_context(self)
                 validator(data)
                 validator(data)
 
 
+        # Enforce model validation
+        super(WritableVLANGroupSerializer, self).validate(data)
+
         return data
         return data
 
 
 
 
@@ -187,6 +191,9 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
                 validator.set_context(self)
                 validator.set_context(self)
                 validator(data)
                 validator(data)
 
 
+        # Enforce model validation
+        super(WritableVLANSerializer, self).validate(data)
+
         return data
         return data
 
 
 
 
@@ -236,12 +243,13 @@ class IPAddressSerializer(CustomFieldModelSerializer):
     vrf = NestedVRFSerializer()
     vrf = NestedVRFSerializer()
     tenant = NestedTenantSerializer()
     tenant = NestedTenantSerializer()
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
+    role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
     interface = InterfaceSerializer()
     interface = InterfaceSerializer()
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
+            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
             'nat_outside', 'custom_fields',
             'nat_outside', 'custom_fields',
         ]
         ]
 
 
@@ -261,7 +269,24 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
+        fields = [
+            'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
+            'custom_fields',
+        ]
+
+
+class AvailableIPSerializer(serializers.Serializer):
+
+    def to_representation(self, instance):
+        if self.context.get('vrf'):
+            vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
+        else:
+            vrf = None
+        return OrderedDict([
+            ('family', self.context['prefix'].version),
+            ('address', '{}/{}'.format(instance, self.context['prefix'].prefixlen)),
+            ('vrf', vrf),
+        ])
 
 
 
 
 #
 #
@@ -278,6 +303,7 @@ class ServiceSerializer(serializers.ModelSerializer):
         fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
         fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
 
 
 
 
+# TODO: Figure out how to use ModelValidationMixin with ManyToManyFields. Calling clean() yields a ValueError.
 class WritableServiceSerializer(serializers.ModelSerializer):
 class WritableServiceSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:

+ 73 - 9
netbox/ipam/api/views.py

@@ -1,7 +1,14 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from rest_framework import status
+from rest_framework.decorators import detail_route
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
 from rest_framework.viewsets import ModelViewSet
 from rest_framework.viewsets import ModelViewSet
 
 
+from django.conf import settings
+from django.shortcuts import get_object_or_404
+
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam import filters
 from ipam import filters
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
@@ -20,15 +27,6 @@ class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     filter_class = filters.VRFFilter
     filter_class = filters.VRFFilter
 
 
 
 
-#
-# Roles
-#
-
-class RoleViewSet(ModelViewSet):
-    queryset = Role.objects.all()
-    serializer_class = serializers.RoleSerializer
-
-
 #
 #
 # RIRs
 # RIRs
 #
 #
@@ -50,6 +48,16 @@ class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     filter_class = filters.AggregateFilter
     filter_class = filters.AggregateFilter
 
 
 
 
+#
+# Roles
+#
+
+class RoleViewSet(ModelViewSet):
+    queryset = Role.objects.all()
+    serializer_class = serializers.RoleSerializer
+    filter_class = filters.RoleFilter
+
+
 #
 #
 # Prefixes
 # Prefixes
 #
 #
@@ -60,6 +68,62 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     write_serializer_class = serializers.WritablePrefixSerializer
     write_serializer_class = serializers.WritablePrefixSerializer
     filter_class = filters.PrefixFilter
     filter_class = filters.PrefixFilter
 
 
+    @detail_route(url_path='available-ips', methods=['get', 'post'])
+    def available_ips(self, request, pk=None):
+        """
+        A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
+        returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
+        however results will not be paginated.
+        """
+        prefix = get_object_or_404(Prefix, pk=pk)
+
+        # Create the next available IP within the prefix
+        if request.method == 'POST':
+
+            # Permissions check
+            if not request.user.has_perm('ipam.add_ipaddress'):
+                raise PermissionDenied()
+
+            # Find the first available IP address in the prefix
+            try:
+                ipaddress = list(prefix.get_available_ips())[0]
+            except IndexError:
+                return Response(
+                    {
+                        "detail": "There are no available IPs within this prefix ({})".format(prefix)
+                    },
+                    status=status.HTTP_400_BAD_REQUEST
+                )
+
+            # Create the new IP address
+            data = request.data.copy()
+            data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
+            data['vrf'] = prefix.vrf
+            serializer = serializers.WritableIPAddressSerializer(data=data)
+            if serializer.is_valid():
+                serializer.save()
+                return Response(serializer.data, status=status.HTTP_201_CREATED)
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+        # Determine the maximum amount of IPs to return
+        else:
+            try:
+                limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
+            except ValueError:
+                limit = settings.PAGINATE_COUNT
+            if settings.MAX_PAGE_SIZE:
+                limit = min(limit, settings.MAX_PAGE_SIZE)
+
+            # Calculate available IPs within the prefix
+            ip_list = list(prefix.get_available_ips())[:limit]
+            serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
+                'request': request,
+                'prefix': prefix.prefix,
+                'vrf': prefix.vrf,
+            })
+
+            return Response(serializer.data)
+
 
 
 #
 #
 # IP addresses
 # IP addresses

+ 78 - 0
netbox/ipam/constants.py

@@ -0,0 +1,78 @@
+from __future__ import unicode_literals
+
+
+# IP address families
+AF_CHOICES = (
+    (4, 'IPv4'),
+    (6, 'IPv6'),
+)
+
+# Prefix statuses
+PREFIX_STATUS_CONTAINER = 0
+PREFIX_STATUS_ACTIVE = 1
+PREFIX_STATUS_RESERVED = 2
+PREFIX_STATUS_DEPRECATED = 3
+PREFIX_STATUS_CHOICES = (
+    (PREFIX_STATUS_CONTAINER, 'Container'),
+    (PREFIX_STATUS_ACTIVE, 'Active'),
+    (PREFIX_STATUS_RESERVED, 'Reserved'),
+    (PREFIX_STATUS_DEPRECATED, 'Deprecated')
+)
+
+# IP address statuses
+IPADDRESS_STATUS_ACTIVE = 1
+IPADDRESS_STATUS_RESERVED = 2
+IPADDRESS_STATUS_DEPRECATED = 3
+IPADDRESS_STATUS_DHCP = 5
+IPADDRESS_STATUS_CHOICES = (
+    (IPADDRESS_STATUS_ACTIVE, 'Active'),
+    (IPADDRESS_STATUS_RESERVED, 'Reserved'),
+    (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
+    (IPADDRESS_STATUS_DHCP, 'DHCP')
+)
+
+# IP address roles
+IPADDRESS_ROLE_LOOPBACK = 10
+IPADDRESS_ROLE_SECONDARY = 20
+IPADDRESS_ROLE_ANYCAST = 30
+IPADDRESS_ROLE_VIP = 40
+IPADDRESS_ROLE_VRRP = 41
+IPADDRESS_ROLE_HSRP = 42
+IPADDRESS_ROLE_GLBP = 43
+IPADDRESS_ROLE_CHOICES = (
+    (IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
+    (IPADDRESS_ROLE_SECONDARY, 'Secondary'),
+    (IPADDRESS_ROLE_ANYCAST, 'Anycast'),
+    (IPADDRESS_ROLE_VIP, 'VIP'),
+    (IPADDRESS_ROLE_VRRP, 'VRRP'),
+    (IPADDRESS_ROLE_HSRP, 'HSRP'),
+    (IPADDRESS_ROLE_GLBP, 'GLBP'),
+)
+
+# VLAN statuses
+VLAN_STATUS_ACTIVE = 1
+VLAN_STATUS_RESERVED = 2
+VLAN_STATUS_DEPRECATED = 3
+VLAN_STATUS_CHOICES = (
+    (VLAN_STATUS_ACTIVE, 'Active'),
+    (VLAN_STATUS_RESERVED, 'Reserved'),
+    (VLAN_STATUS_DEPRECATED, 'Deprecated')
+)
+
+# Bootstrap CSS classes for various statuses
+STATUS_CHOICE_CLASSES = {
+    0: 'default',
+    1: 'primary',
+    2: 'info',
+    3: 'danger',
+    4: 'warning',
+    5: 'success',
+}
+
+# IP protocols (for services)
+IP_PROTOCOL_TCP = 6
+IP_PROTOCOL_UDP = 17
+IP_PROTOCOL_CHOICES = (
+    (IP_PROTOCOL_TCP, 'TCP'),
+    (IP_PROTOCOL_UDP, 'UDP'),
+)

+ 17 - 23
netbox/ipam/filters.py

@@ -11,8 +11,8 @@ from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import (
 from .models import (
-    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
-    VLAN_STATUS_CHOICES, VLANGroup, VRF,
+    Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
+    Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
 )
 )
 
 
 
 
@@ -23,7 +23,6 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -45,7 +44,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd']
+        fields = ['name', 'rd', 'enforce_unique']
 
 
 
 
 class RIRFilter(django_filters.FilterSet):
 class RIRFilter(django_filters.FilterSet):
@@ -53,7 +52,7 @@ class RIRFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ['is_private']
+        fields = ['name', 'slug', 'is_private']
 
 
 
 
 class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -63,7 +62,6 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     rir_id = django_filters.ModelMultipleChoiceFilter(
     rir_id = django_filters.ModelMultipleChoiceFilter(
-        name='rir',
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         label='RIR (ID)',
         label='RIR (ID)',
     )
     )
@@ -90,6 +88,13 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
+class RoleFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = Role
+        fields = ['name', 'slug']
+
+
 class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -105,7 +110,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Mask length',
         label='Mask length',
     )
     )
     vrf_id = NullableModelMultipleChoiceFilter(
     vrf_id = NullableModelMultipleChoiceFilter(
-        name='vrf_id',
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         label='VRF',
         label='VRF',
     )
     )
@@ -116,7 +120,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -127,7 +130,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
     site_id = NullableModelMultipleChoiceFilter(
     site_id = NullableModelMultipleChoiceFilter(
-        name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
@@ -138,7 +140,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (slug)',
         label='Site (slug)',
     )
     )
     vlan_id = NullableModelMultipleChoiceFilter(
     vlan_id = NullableModelMultipleChoiceFilter(
-        name='vlan',
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         label='VLAN (ID)',
         label='VLAN (ID)',
     )
     )
@@ -147,7 +148,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='VLAN number (1-4095)',
         label='VLAN number (1-4095)',
     )
     )
     role_id = NullableModelMultipleChoiceFilter(
     role_id = NullableModelMultipleChoiceFilter(
-        name='role',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
@@ -163,7 +163,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['family']
+        fields = ['family', 'is_pool']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -207,7 +207,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Mask length',
         label='Mask length',
     )
     )
     vrf_id = NullableModelMultipleChoiceFilter(
     vrf_id = NullableModelMultipleChoiceFilter(
-        name='vrf_id',
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         label='VRF',
         label='VRF',
     )
     )
@@ -218,7 +217,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -240,13 +238,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Device (name)',
         label='Device (name)',
     )
     )
     interface_id = django_filters.ModelMultipleChoiceFilter(
     interface_id = django_filters.ModelMultipleChoiceFilter(
-        name='interface',
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface (ID)',
         label='Interface (ID)',
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=IPADDRESS_STATUS_CHOICES
         choices=IPADDRESS_STATUS_CHOICES
     )
     )
+    role = django_filters.MultipleChoiceFilter(
+        choices=IPADDRESS_ROLE_CHOICES
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -281,7 +281,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class VLANGroupFilter(django_filters.FilterSet):
 class VLANGroupFilter(django_filters.FilterSet):
     site_id = NullableModelMultipleChoiceFilter(
     site_id = NullableModelMultipleChoiceFilter(
-        name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
@@ -294,7 +293,7 @@ class VLANGroupFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['name']
+        fields = ['name', 'slug']
 
 
 
 
 class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -304,7 +303,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     site_id = NullableModelMultipleChoiceFilter(
     site_id = NullableModelMultipleChoiceFilter(
-        name='site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
@@ -315,7 +313,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Site (slug)',
         label='Site (slug)',
     )
     )
     group_id = NullableModelMultipleChoiceFilter(
     group_id = NullableModelMultipleChoiceFilter(
-        name='group',
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         label='Group (ID)',
         label='Group (ID)',
     )
     )
@@ -326,7 +323,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Group',
         label='Group',
     )
     )
     tenant_id = NullableModelMultipleChoiceFilter(
     tenant_id = NullableModelMultipleChoiceFilter(
-        name='tenant',
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         label='Tenant (ID)',
         label='Tenant (ID)',
     )
     )
@@ -337,7 +333,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
     role_id = NullableModelMultipleChoiceFilter(
     role_id = NullableModelMultipleChoiceFilter(
-        name='role',
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
@@ -353,7 +348,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['name', 'vid']
+        fields = ['vid', 'name']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -368,7 +363,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
 class ServiceFilter(django_filters.FilterSet):
 class ServiceFilter(django_filters.FilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(
     device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
     )
     )

+ 22 - 8
netbox/ipam/forms.py

@@ -14,8 +14,8 @@ from utilities.forms import (
     add_blank_choice,
     add_blank_choice,
 )
 )
 from .models import (
 from .models import (
-    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
-    VLANGroup, VLAN_STATUS_CHOICES, VRF,
+    Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
+    Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
 )
 )
 
 
 
 
@@ -217,7 +217,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 
         # Initialize helper selectors
         # Initialize helper selectors
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
+        initial = kwargs.get('initial', {}).copy()
         if instance and instance.vlan is not None:
         if instance and instance.vlan is not None:
             initial['vlan_group'] = instance.vlan.group
             initial['vlan_group'] = instance.vlan.group
         kwargs['initial'] = initial
         kwargs['initial'] = initial
@@ -484,7 +484,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
+            'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
             'nat_inside', 'tenant_group', 'tenant',
             'nat_inside', 'tenant_group', 'tenant',
         ]
         ]
 
 
@@ -492,7 +492,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
 
 
         # Initialize helper selectors
         # Initialize helper selectors
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
-        initial = kwargs.get('initial', {})
+        initial = kwargs.get('initial', {}).copy()
         if instance and instance.interface is not None:
         if instance and instance.interface is not None:
             initial['interface_site'] = instance.interface.device.site
             initial['interface_site'] = instance.interface.device.site
             initial['interface_rack'] = instance.interface.device.rack
             initial['interface_rack'] = instance.interface.device.rack
@@ -562,7 +562,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
+        fields = ['address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
         super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
@@ -592,6 +592,11 @@ class IPAddressCSVForm(forms.ModelForm):
         choices=IPADDRESS_STATUS_CHOICES,
         choices=IPADDRESS_STATUS_CHOICES,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
+    role = CSVChoiceField(
+        choices=IPADDRESS_ROLE_CHOICES,
+        required=False,
+        help_text='Functional role'
+    )
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -612,7 +617,7 @@ class IPAddressCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
+        fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description']
 
 
     def clean(self):
     def clean(self):
 
 
@@ -665,10 +670,11 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
     status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False)
     status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False)
+    role = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), required=False)
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['vrf', 'tenant', 'description']
+        nullable_fields = ['vrf', 'role', 'tenant', 'description']
 
 
 
 
 def ipaddress_status_choices():
 def ipaddress_status_choices():
@@ -678,6 +684,13 @@ def ipaddress_status_choices():
     return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
     return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
 
 
 
 
+def ipaddress_role_choices():
+    role_counts = {}
+    for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
+        role_counts[role['role']] = role['count']
+    return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
+
+
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
     model = IPAddress
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -698,6 +711,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         null_option=(0, 'None')
         null_option=(0, 'None')
     )
     )
     status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
     status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
+    role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
 
 
 
 
 #
 #

+ 25 - 0
netbox/ipam/migrations/0017_ipaddress_roles.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.1 on 2017-06-16 19:37
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0016_unicode_literals'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ipaddress',
+            name='role',
+            field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'),
+        ),
+    ]

+ 47 - 63
netbox/ipam/models.py

@@ -1,6 +1,5 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
-
-from netaddr import IPNetwork, IPSet
+import netaddr
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
@@ -17,63 +16,10 @@ from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 from utilities.sql import NullsFirstQuerySet
 from utilities.sql import NullsFirstQuerySet
 from utilities.utils import csv_format
 from utilities.utils import csv_format
+from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
 
 
 
 
-AF_CHOICES = (
-    (4, 'IPv4'),
-    (6, 'IPv6'),
-)
-
-PREFIX_STATUS_CONTAINER = 0
-PREFIX_STATUS_ACTIVE = 1
-PREFIX_STATUS_RESERVED = 2
-PREFIX_STATUS_DEPRECATED = 3
-PREFIX_STATUS_CHOICES = (
-    (PREFIX_STATUS_CONTAINER, 'Container'),
-    (PREFIX_STATUS_ACTIVE, 'Active'),
-    (PREFIX_STATUS_RESERVED, 'Reserved'),
-    (PREFIX_STATUS_DEPRECATED, 'Deprecated')
-)
-
-IPADDRESS_STATUS_ACTIVE = 1
-IPADDRESS_STATUS_RESERVED = 2
-IPADDRESS_STATUS_DEPRECATED = 3
-IPADDRESS_STATUS_DHCP = 5
-IPADDRESS_STATUS_CHOICES = (
-    (IPADDRESS_STATUS_ACTIVE, 'Active'),
-    (IPADDRESS_STATUS_RESERVED, 'Reserved'),
-    (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
-    (IPADDRESS_STATUS_DHCP, 'DHCP')
-)
-
-VLAN_STATUS_ACTIVE = 1
-VLAN_STATUS_RESERVED = 2
-VLAN_STATUS_DEPRECATED = 3
-VLAN_STATUS_CHOICES = (
-    (VLAN_STATUS_ACTIVE, 'Active'),
-    (VLAN_STATUS_RESERVED, 'Reserved'),
-    (VLAN_STATUS_DEPRECATED, 'Deprecated')
-)
-
-STATUS_CHOICE_CLASSES = {
-    0: 'default',
-    1: 'primary',
-    2: 'info',
-    3: 'danger',
-    4: 'warning',
-    5: 'success',
-}
-
-
-IP_PROTOCOL_TCP = 6
-IP_PROTOCOL_UDP = 17
-IP_PROTOCOL_CHOICES = (
-    (IP_PROTOCOL_TCP, 'TCP'),
-    (IP_PROTOCOL_UDP, 'UDP'),
-)
-
-
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class VRF(CreatedUpdatedModel, CustomFieldModel):
 class VRF(CreatedUpdatedModel, CustomFieldModel):
     """
     """
@@ -97,7 +43,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         verbose_name_plural = 'VRFs'
         verbose_name_plural = 'VRFs'
 
 
     def __str__(self):
     def __str__(self):
-        return self.name
+        return self.display_name or super(VRF, self).__str__()
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:vrf', args=[self.pk])
         return reverse('ipam:vrf', args=[self.pk])
@@ -111,6 +57,12 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
             self.description,
             self.description,
         ])
         ])
 
 
+    @property
+    def display_name(self):
+        if self.name and self.rd:
+            return "{} ({})".format(self.name, self.rd)
+        return None
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class RIR(models.Model):
 class RIR(models.Model):
@@ -207,7 +159,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         Determine the prefix utilization of the aggregate and return it as a percentage.
         Determine the prefix utilization of the aggregate and return it as a percentage.
         """
         """
         queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
         queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
-        child_prefixes = IPSet([p.prefix for p in queryset])
+        child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
         return int(float(child_prefixes.size) / self.prefix.size * 100)
         return int(float(child_prefixes.size) / self.prefix.size * 100)
 
 
 
 
@@ -364,6 +316,29 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     def get_duplicates(self):
     def get_duplicates(self):
         return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
         return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
 
 
+    def get_child_ips(self):
+        """
+        Return all IPAddresses within this Prefix.
+        """
+        return IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf)
+
+    def get_available_ips(self):
+        """
+        Return all available IPs within this prefix as an IPSet.
+        """
+        prefix = netaddr.IPSet(self.prefix)
+        child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
+        available_ips = prefix - child_ips
+
+        # Remove unusable IPs from non-pool prefixes
+        if not self.is_pool:
+            available_ips -= netaddr.IPSet([
+                netaddr.IPAddress(self.prefix.first),
+                netaddr.IPAddress(self.prefix.last),
+            ])
+
+        return available_ips
+
     def get_utilization(self):
     def get_utilization(self):
         """
         """
         Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
         Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
@@ -371,7 +346,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
         """
         """
         if self.status == PREFIX_STATUS_CONTAINER:
         if self.status == PREFIX_STATUS_CONTAINER:
             queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
             queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
-            child_prefixes = IPSet([p.prefix for p in queryset])
+            child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             return int(float(child_prefixes.size) / self.prefix.size * 100)
             return int(float(child_prefixes.size) / self.prefix.size * 100)
         else:
         else:
             child_count = IPAddress.objects.filter(
             child_count = IPAddress.objects.filter(
@@ -386,11 +361,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     def new_subnet(self):
     def new_subnet(self):
         if self.family == 4:
         if self.family == 4:
             if self.prefix.prefixlen <= 30:
             if self.prefix.prefixlen <= 30:
-                return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
+                return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
             return None
             return None
         if self.family == 6:
         if self.family == 6:
             if self.prefix.prefixlen <= 126:
             if self.prefix.prefixlen <= 126:
-                return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
+                return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
             return None
             return None
 
 
 
 
@@ -425,7 +400,13 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
     vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
                             verbose_name='VRF')
                             verbose_name='VRF')
     tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
     tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
-    status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1)
+    status = models.PositiveSmallIntegerField(
+        'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE,
+        help_text='The operational status of this IP'
+    )
+    role = models.PositiveSmallIntegerField(
+        'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP'
+    )
     interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
     interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
                                   null=True)
                                   null=True)
     nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
     nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
@@ -436,7 +417,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
 
 
-    csv_headers = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
+    csv_headers = [
+        'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description',
+    ]
 
 
     class Meta:
     class Meta:
         ordering = ['family', 'address']
         ordering = ['family', 'address']
@@ -488,6 +471,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             self.vrf.rd if self.vrf else None,
             self.vrf.rd if self.vrf else None,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.get_status_display(),
             self.get_status_display(),
+            self.get_role_display(),
             self.device.identifier if self.device else None,
             self.device.identifier if self.device else None,
             self.interface.name if self.interface else None,
             self.interface.name if self.interface else None,
             is_primary,
             is_primary,

+ 37 - 87
netbox/ipam/tables.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from utilities.tables import BaseTable, SearchTable, ToggleColumn
+from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
 
 
@@ -152,16 +152,6 @@ class VRFTable(BaseTable):
         fields = ('pk', 'name', 'rd', 'tenant', 'description')
         fields = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
 
 
-class VRFSearchTable(SearchTable):
-    name = tables.LinkColumn()
-    rd = tables.Column(verbose_name='RD')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-
-    class Meta(SearchTable.Meta):
-        model = VRF
-        fields = ('name', 'rd', 'tenant', 'description')
-
-
 #
 #
 # RIRs
 # RIRs
 #
 #
@@ -171,6 +161,14 @@ class RIRTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     name = tables.LinkColumn(verbose_name='Name')
     is_private = tables.BooleanColumn(verbose_name='Private')
     is_private = tables.BooleanColumn(verbose_name='Private')
     aggregate_count = tables.Column(verbose_name='Aggregates')
     aggregate_count = tables.Column(verbose_name='Aggregates')
+    actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
+
+    class Meta(BaseTable.Meta):
+        model = RIR
+        fields = ('pk', 'name', 'is_private', 'aggregate_count', 'actions')
+
+
+class RIRDetailTable(RIRTable):
     stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
     stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
                                 footer=lambda table: sum(r.stats['total'] for r in table.data))
                                 footer=lambda table: sum(r.stats['total'] for r in table.data))
     stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
     stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
@@ -182,12 +180,12 @@ class RIRTable(BaseTable):
     stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
     stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
                                     footer=lambda table: sum(r.stats['available'] for r in table.data))
                                     footer=lambda table: sum(r.stats['available'] for r in table.data))
     utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
     utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
-    actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
 
-    class Meta(BaseTable.Meta):
-        model = RIR
-        fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
-                  'stats_deprecated', 'stats_available', 'utilization', 'actions')
+    class Meta(RIRTable.Meta):
+        fields = (
+            'pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
+            'stats_deprecated', 'stats_available', 'utilization', 'actions',
+        )
 
 
 
 
 #
 #
@@ -197,22 +195,19 @@ class RIRTable(BaseTable):
 class AggregateTable(BaseTable):
 class AggregateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     prefix = tables.LinkColumn(verbose_name='Aggregate')
     prefix = tables.LinkColumn(verbose_name='Aggregate')
-    child_count = tables.Column(verbose_name='Prefixes')
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Aggregate
         model = Aggregate
-        fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
+        fields = ('pk', 'prefix', 'rir', 'date_added', 'description')
 
 
 
 
-class AggregateSearchTable(SearchTable):
-    prefix = tables.LinkColumn(verbose_name='Aggregate')
-    date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
+class AggregateDetailTable(AggregateTable):
+    child_count = tables.Column(verbose_name='Prefixes')
+    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
-    class Meta(SearchTable.Meta):
-        model = Aggregate
-        fields = ('prefix', 'rir', 'date_added', 'description')
+    class Meta(AggregateTable.Meta):
+        fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
 
 
 
 
 #
 #
@@ -241,7 +236,6 @@ class PrefixTable(BaseTable):
     prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
     prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
     status = tables.TemplateColumn(STATUS_LABEL)
     status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     tenant = tables.TemplateColumn(TENANT_LINK)
     tenant = tables.TemplateColumn(TENANT_LINK)
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
     vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
@@ -249,37 +243,17 @@ class PrefixTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not record.pk else '',
             'class': lambda record: 'success' if not record.pk else '',
         }
         }
 
 
 
 
-class PrefixBriefTable(BaseTable):
-    prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF)
-    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    status = tables.TemplateColumn(STATUS_LABEL)
-    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')])
-
-    class Meta(BaseTable.Meta):
-        model = Prefix
-        fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
-        orderable = False
-
-
-class PrefixSearchTable(SearchTable):
-    prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
-    status = tables.TemplateColumn(STATUS_LABEL)
-    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK)
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
-    role = tables.TemplateColumn(PREFIX_ROLE_LINK)
+class PrefixDetailTable(PrefixTable):
+    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
-    class Meta(SearchTable.Meta):
-        model = Prefix
-        fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
+    class Meta(PrefixTable.Meta):
+        fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
 
 
 
 
 #
 #
@@ -292,43 +266,26 @@ class IPAddressTable(BaseTable):
     status = tables.TemplateColumn(STATUS_LABEL)
     status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK)
     tenant = tables.TemplateColumn(TENANT_LINK)
-    nat_inside = tables.LinkColumn(
-        'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
-    )
     device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
     device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
+    interface = tables.Column(orderable=False)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
-        fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description')
+        fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
         }
         }
 
 
 
 
-class IPAddressBriefTable(BaseTable):
-    address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
-    interface = tables.Column(orderable=False)
+class IPAddressDetailTable(IPAddressTable):
     nat_inside = tables.LinkColumn(
     nat_inside = tables.LinkColumn(
         'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
         'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
-        model = IPAddress
-        fields = ('address', 'device', 'interface', 'nat_inside')
-
-
-class IPAddressSearchTable(SearchTable):
-    address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
-    status = tables.TemplateColumn(STATUS_LABEL)
-    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK)
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
-    interface = tables.Column(orderable=False)
-
-    class Meta(SearchTable.Meta):
-        model = IPAddress
-        fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
+    class Meta(IPAddressTable.Meta):
+        fields = (
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
+        )
 
 
 
 
 #
 #
@@ -358,24 +315,17 @@ class VLANTable(BaseTable):
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     status = tables.TemplateColumn(STATUS_LABEL)
     status = tables.TemplateColumn(STATUS_LABEL)
     role = tables.TemplateColumn(VLAN_ROLE_LINK)
     role = tables.TemplateColumn(VLAN_ROLE_LINK)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
-        fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
 
 
 
 
-class VLANSearchTable(SearchTable):
-    vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
-    status = tables.TemplateColumn(STATUS_LABEL)
-    role = tables.TemplateColumn(VLAN_ROLE_LINK)
+class VLANDetailTable(VLANTable):
+    prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
 
 
-    class Meta(SearchTable.Meta):
-        model = VLAN
-        fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
+    class Meta(VLANTable.Meta):
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

+ 29 - 0
netbox/ipam/tests/test_api.py

@@ -367,6 +367,35 @@ class PrefixTest(HttpStatusMixin, APITestCase):
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertEqual(Prefix.objects.count(), 2)
         self.assertEqual(Prefix.objects.count(), 2)
 
 
+    def test_available_ips(self):
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
+        url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
+
+        # Retrieve all available IPs
+        response = self.client.get(url, **self.header)
+        self.assertEqual(len(response.data), 8)  # 8 because prefix.is_pool = True
+
+        # Change the prefix to not be a pool and try again
+        prefix.is_pool = False
+        prefix.save()
+        response = self.client.get(url, **self.header)
+        self.assertEqual(len(response.data), 6)  # 8 - 2 because prefix.is_pool = False
+
+        # Create all six available IPs
+        for i in range(6):
+            data = {
+                'description': 'Test IP {}'.format(i)
+            }
+            response = self.client.post(url, data, **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            self.assertEqual(response.data['description'], data['description'])
+
+        # Try to create one more IP
+        response = self.client.post(url, {}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('detail', response.data)
+
 
 
 class IPAddressTest(HttpStatusMixin, APITestCase):
 class IPAddressTest(HttpStatusMixin, APITestCase):
 
 

+ 38 - 18
netbox/ipam/views.py

@@ -103,8 +103,8 @@ class VRFView(View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         vrf = get_object_or_404(VRF.objects.all(), pk=pk)
         vrf = get_object_or_404(VRF.objects.all(), pk=pk)
-        prefix_table = tables.PrefixBriefTable(
-            list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
+        prefix_table = tables.PrefixTable(
+            list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')), orderable=False
         )
         )
         prefix_table.exclude = ('vrf',)
         prefix_table.exclude = ('vrf',)
 
 
@@ -142,16 +142,19 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vrf'
     permission_required = 'ipam.change_vrf'
     cls = VRF
     cls = VRF
+    queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
+    table = tables.VRFTable
     form = forms.VRFBulkEditForm
     form = forms.VRFBulkEditForm
-    template_name = 'ipam/vrf_bulk_edit.html'
     default_return_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
+    queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
+    table = tables.VRFTable
     default_return_url = 'ipam:vrf_list'
     default_return_url = 'ipam:vrf_list'
 
 
 
 
@@ -163,7 +166,7 @@ class RIRListView(ObjectListView):
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     filter = filters.RIRFilter
     filter = filters.RIRFilter
     filter_form = forms.RIRFilterForm
     filter_form = forms.RIRFilterForm
-    table = tables.RIRTable
+    table = tables.RIRDetailTable
     template_name = 'ipam/rir_list.html'
     template_name = 'ipam/rir_list.html'
 
 
     def alter_queryset(self, request):
     def alter_queryset(self, request):
@@ -259,7 +262,9 @@ class RIREditView(RIRCreateView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_rir'
     permission_required = 'ipam.delete_rir'
     cls = RIR
     cls = RIR
+    queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     filter = filters.RIRFilter
     filter = filters.RIRFilter
+    table = tables.RIRTable
     default_return_url = 'ipam:rir_list'
     default_return_url = 'ipam:rir_list'
 
 
 
 
@@ -273,7 +278,7 @@ class AggregateListView(ObjectListView):
     })
     })
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
     filter_form = forms.AggregateFilterForm
     filter_form = forms.AggregateFilterForm
-    table = tables.AggregateTable
+    table = tables.AggregateDetailTable
     template_name = 'ipam/aggregate_list.html'
     template_name = 'ipam/aggregate_list.html'
 
 
     def extra_context(self):
     def extra_context(self):
@@ -360,16 +365,19 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_aggregate'
     permission_required = 'ipam.change_aggregate'
     cls = Aggregate
     cls = Aggregate
+    queryset = Aggregate.objects.select_related('rir')
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
+    table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
-    template_name = 'ipam/aggregate_bulk_edit.html'
     default_return_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
+    queryset = Aggregate.objects.select_related('rir')
     filter = filters.AggregateFilter
     filter = filters.AggregateFilter
+    table = tables.AggregateTable
     default_return_url = 'ipam:aggregate_list'
     default_return_url = 'ipam:aggregate_list'
 
 
 
 
@@ -399,6 +407,7 @@ class RoleEditView(RoleCreateView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_role'
     permission_required = 'ipam.delete_role'
     cls = Role
     cls = Role
+    table = tables.RoleTable
     default_return_url = 'ipam:role_list'
     default_return_url = 'ipam:role_list'
 
 
 
 
@@ -410,7 +419,7 @@ class PrefixListView(ObjectListView):
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
-    table = tables.PrefixTable
+    table = tables.PrefixDetailTable
     template_name = 'ipam/prefix_list.html'
     template_name = 'ipam/prefix_list.html'
 
 
     def alter_queryset(self, request):
     def alter_queryset(self, request):
@@ -445,7 +454,7 @@ class PrefixView(View):
         ).select_related(
         ).select_related(
             'site', 'role'
             'site', 'role'
         ).annotate_depth()
         ).annotate_depth()
-        parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
+        parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
         parent_prefix_table.exclude = ('vrf',)
         parent_prefix_table.exclude = ('vrf',)
 
 
         # Duplicate prefixes table
         # Duplicate prefixes table
@@ -456,7 +465,7 @@ class PrefixView(View):
         ).select_related(
         ).select_related(
             'site', 'role'
             'site', 'role'
         )
         )
-        duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
+        duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
         duplicate_prefix_table.exclude = ('vrf',)
         duplicate_prefix_table.exclude = ('vrf',)
 
 
         # Child prefixes table
         # Child prefixes table
@@ -564,16 +573,19 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_prefix'
     permission_required = 'ipam.change_prefix'
     cls = Prefix
     cls = Prefix
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
+    table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
     form = forms.PrefixBulkEditForm
-    template_name = 'ipam/prefix_bulk_edit.html'
     default_return_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
+    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
+    table = tables.PrefixTable
     default_return_url = 'ipam:prefix_list'
     default_return_url = 'ipam:prefix_list'
 
 
 
 
@@ -585,7 +597,7 @@ class IPAddressListView(ObjectListView):
     queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
     queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     filter_form = forms.IPAddressFilterForm
-    table = tables.IPAddressTable
+    table = tables.IPAddressDetailTable
     template_name = 'ipam/ipaddress_list.html'
     template_name = 'ipam/ipaddress_list.html'
 
 
 
 
@@ -601,7 +613,7 @@ class IPAddressView(View):
         ).select_related(
         ).select_related(
             'site', 'role'
             'site', 'role'
         )
         )
-        parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
+        parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
         parent_prefixes_table.exclude = ('vrf',)
         parent_prefixes_table.exclude = ('vrf',)
 
 
         # Duplicate IPs table
         # Duplicate IPs table
@@ -612,7 +624,7 @@ class IPAddressView(View):
         ).select_related(
         ).select_related(
             'interface__device', 'nat_inside'
             'interface__device', 'nat_inside'
         )
         )
-        duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
+        duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
 
 
         # Related IP table
         # Related IP table
         related_ips = IPAddress.objects.select_related(
         related_ips = IPAddress.objects.select_related(
@@ -622,7 +634,7 @@ class IPAddressView(View):
         ).filter(
         ).filter(
             vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
             vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
         )
         )
-        related_ips_table = tables.IPAddressBriefTable(list(related_ips))
+        related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False)
 
 
         return render(request, 'ipam/ipaddress.html', {
         return render(request, 'ipam/ipaddress.html', {
             'ipaddress': ipaddress,
             'ipaddress': ipaddress,
@@ -669,16 +681,19 @@ 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
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
+    table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
-    template_name = 'ipam/ipaddress_bulk_edit.html'
     default_return_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
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
+    table = tables.IPAddressTable
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
 
 
@@ -710,7 +725,9 @@ class VLANGroupEditView(VLANGroupCreateView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     permission_required = 'ipam.delete_vlangroup'
     cls = VLANGroup
     cls = VLANGroup
+    queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter = filters.VLANGroupFilter
+    table = tables.VLANGroupTable
     default_return_url = 'ipam:vlangroup_list'
     default_return_url = 'ipam:vlangroup_list'
 
 
 
 
@@ -722,7 +739,7 @@ class VLANListView(ObjectListView):
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
-    table = tables.VLANTable
+    table = tables.VLANDetailTable
     template_name = 'ipam/vlan_list.html'
     template_name = 'ipam/vlan_list.html'
 
 
 
 
@@ -734,7 +751,7 @@ class VLANView(View):
             'site__region', 'tenant__group', 'role'
             'site__region', 'tenant__group', 'role'
         ), pk=pk)
         ), 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.PrefixTable(list(prefixes), orderable=False)
         prefix_table.exclude = ('vlan',)
         prefix_table.exclude = ('vlan',)
 
 
         return render(request, 'ipam/vlan.html', {
         return render(request, 'ipam/vlan.html', {
@@ -771,16 +788,19 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
 class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_vlan'
     permission_required = 'ipam.change_vlan'
     cls = VLAN
     cls = VLAN
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
+    table = tables.VLANTable
     form = forms.VLANBulkEditForm
     form = forms.VLANBulkEditForm
-    template_name = 'ipam/vlan_bulk_edit.html'
     default_return_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
+    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
+    table = tables.VLANTable
     default_return_url = 'ipam:vlan_list'
     default_return_url = 'ipam:vlan_list'
 
 
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.0.11-dev'
+VERSION = '2.1.0-dev'
 
 
 # Import required configuration parameters
 # Import required configuration parameters
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

+ 22 - 20
netbox/netbox/views.py

@@ -11,20 +11,20 @@ from django.views.generic import View
 
 
 from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.models import Circuit, Provider
-from circuits.tables import CircuitSearchTable, ProviderSearchTable
+from circuits.tables import CircuitTable, ProviderTable
 from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
 from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
 from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
 from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
-from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable
+from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable
 from extras.models import TopologyMap, UserAction
 from extras.models import TopologyMap, UserAction
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
-from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable
+from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 from secrets.filters import SecretFilter
 from secrets.filters import SecretFilter
 from secrets.models import Secret
 from secrets.models import Secret
-from secrets.tables import SecretSearchTable
+from secrets.tables import SecretTable
 from tenancy.filters import TenantFilter
 from tenancy.filters import TenantFilter
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from tenancy.tables import TenantSearchTable
+from tenancy.tables import TenantTable
 from .forms import SearchForm
 from .forms import SearchForm
 
 
 
 
@@ -34,83 +34,85 @@ SEARCH_TYPES = OrderedDict((
     ('provider', {
     ('provider', {
         'queryset': Provider.objects.all(),
         'queryset': Provider.objects.all(),
         'filter': ProviderFilter,
         'filter': ProviderFilter,
-        'table': ProviderSearchTable,
+        'table': ProviderTable,
         'url': 'circuits:provider_list',
         'url': 'circuits:provider_list',
     }),
     }),
     ('circuit', {
     ('circuit', {
         'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
         'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
         'filter': CircuitFilter,
         'filter': CircuitFilter,
-        'table': CircuitSearchTable,
+        'table': CircuitTable,
         'url': 'circuits:circuit_list',
         'url': 'circuits:circuit_list',
     }),
     }),
     # DCIM
     # DCIM
     ('site', {
     ('site', {
         'queryset': Site.objects.select_related('region', 'tenant'),
         'queryset': Site.objects.select_related('region', 'tenant'),
         'filter': SiteFilter,
         'filter': SiteFilter,
-        'table': SiteSearchTable,
+        'table': SiteTable,
         'url': 'dcim:site_list',
         'url': 'dcim:site_list',
     }),
     }),
     ('rack', {
     ('rack', {
         'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
         'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
         'filter': RackFilter,
         'filter': RackFilter,
-        'table': RackSearchTable,
+        'table': RackTable,
         'url': 'dcim:rack_list',
         'url': 'dcim:rack_list',
     }),
     }),
     ('devicetype', {
     ('devicetype', {
         'queryset': DeviceType.objects.select_related('manufacturer'),
         'queryset': DeviceType.objects.select_related('manufacturer'),
         'filter': DeviceTypeFilter,
         'filter': DeviceTypeFilter,
-        'table': DeviceTypeSearchTable,
+        'table': DeviceTypeTable,
         'url': 'dcim:devicetype_list',
         'url': 'dcim:devicetype_list',
     }),
     }),
     ('device', {
     ('device', {
-        'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
+        'queryset': Device.objects.select_related(
+            'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'
+        ),
         'filter': DeviceFilter,
         'filter': DeviceFilter,
-        'table': DeviceSearchTable,
+        'table': DeviceTable,
         'url': 'dcim:device_list',
         'url': 'dcim:device_list',
     }),
     }),
     # IPAM
     # IPAM
     ('vrf', {
     ('vrf', {
         'queryset': VRF.objects.select_related('tenant'),
         'queryset': VRF.objects.select_related('tenant'),
         'filter': VRFFilter,
         'filter': VRFFilter,
-        'table': VRFSearchTable,
+        'table': VRFTable,
         'url': 'ipam:vrf_list',
         'url': 'ipam:vrf_list',
     }),
     }),
     ('aggregate', {
     ('aggregate', {
         'queryset': Aggregate.objects.select_related('rir'),
         'queryset': Aggregate.objects.select_related('rir'),
         'filter': AggregateFilter,
         'filter': AggregateFilter,
-        'table': AggregateSearchTable,
+        'table': AggregateTable,
         'url': 'ipam:aggregate_list',
         'url': 'ipam:aggregate_list',
     }),
     }),
     ('prefix', {
     ('prefix', {
         'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
         'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
         'filter': PrefixFilter,
         'filter': PrefixFilter,
-        'table': PrefixSearchTable,
+        'table': PrefixTable,
         'url': 'ipam:prefix_list',
         'url': 'ipam:prefix_list',
     }),
     }),
     ('ipaddress', {
     ('ipaddress', {
         'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
         'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
         'filter': IPAddressFilter,
         'filter': IPAddressFilter,
-        'table': IPAddressSearchTable,
+        'table': IPAddressTable,
         'url': 'ipam:ipaddress_list',
         'url': 'ipam:ipaddress_list',
     }),
     }),
     ('vlan', {
     ('vlan', {
         'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
         'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
         'filter': VLANFilter,
         'filter': VLANFilter,
-        'table': VLANSearchTable,
+        'table': VLANTable,
         'url': 'ipam:vlan_list',
         'url': 'ipam:vlan_list',
     }),
     }),
     # Secrets
     # Secrets
     ('secret', {
     ('secret', {
         'queryset': Secret.objects.select_related('role', 'device'),
         'queryset': Secret.objects.select_related('role', 'device'),
         'filter': SecretFilter,
         'filter': SecretFilter,
-        'table': SecretSearchTable,
+        'table': SecretTable,
         'url': 'secrets:secret_list',
         'url': 'secrets:secret_list',
     }),
     }),
     # Tenancy
     # Tenancy
     ('tenant', {
     ('tenant', {
         'queryset': Tenant.objects.select_related('group'),
         'queryset': Tenant.objects.select_related('group'),
         'filter': TenantFilter,
         'filter': TenantFilter,
-        'table': TenantSearchTable,
+        'table': TenantTable,
         'url': 'tenancy:tenant_list',
         'url': 'tenancy:tenant_list',
     }),
     }),
 ))
 ))
@@ -189,7 +191,7 @@ class SearchView(View):
 
 
                 # Construct the results table for this object type
                 # Construct the results table for this object type
                 filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs
                 filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs
-                table = table(filtered_queryset)
+                table = table(filtered_queryset, orderable=False)
                 table.paginate(per_page=SEARCH_MAX_RESULTS)
                 table.paginate(per_page=SEARCH_MAX_RESULTS)
 
 
                 if table.page:
                 if table.page:

+ 25 - 0
netbox/project-static/css/base.css

@@ -333,6 +333,31 @@ table.component-list tr.ipaddress:hover td {
     background-color: #e6f7f7;
     background-color: #e6f7f7;
 }
 }
 
 
+/* AJAX loader */
+.loading {
+    position: fixed;
+    display: none;
+    z-index: 999;
+    height: 2em;
+    width: 2em;
+    overflow: show;
+    margin: auto;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    right: 0;
+}
+.loading:before {
+    content: '';
+    display: block;
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0,0,0,0.3);
+}
+
 /* Misc */
 /* Misc */
 .banner-bottom {
 .banner-bottom {
     margin-bottom: 50px;
     margin-bottom: 50px;

BIN
netbox/project-static/img/ajax-loader.gif


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
netbox/project-static/js/jquery-3.2.1.min.js


+ 5 - 1
netbox/secrets/api/serializers.py

@@ -5,13 +5,14 @@ from rest_framework.validators import UniqueTogetherValidator
 
 
 from dcim.api.serializers import NestedDeviceSerializer
 from dcim.api.serializers import NestedDeviceSerializer
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
+from utilities.api import ModelValidationMixin
 
 
 
 
 #
 #
 # SecretRoles
 # SecretRoles
 #
 #
 
 
-class SecretRoleSerializer(serializers.ModelSerializer):
+class SecretRoleSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
@@ -55,4 +56,7 @@ class WritableSecretSerializer(serializers.ModelSerializer):
             validator.set_context(self)
             validator.set_context(self)
             validator(data)
             validator(data)
 
 
+        # Enforce model validation
+        super(WritableSecretSerializer, self).validate(data)
+
         return data
         return data

+ 1 - 0
netbox/secrets/api/views.py

@@ -30,6 +30,7 @@ class SecretRoleViewSet(ModelViewSet):
     queryset = SecretRole.objects.all()
     queryset = SecretRole.objects.all()
     serializer_class = serializers.SecretRoleSerializer
     serializer_class = serializers.SecretRoleSerializer
     permission_classes = [IsAuthenticated]
     permission_classes = [IsAuthenticated]
+    filter_class = filters.SecretRoleFilter
 
 
 
 
 #
 #

+ 7 - 2
netbox/secrets/filters.py

@@ -9,6 +9,13 @@ from dcim.models import Device
 from utilities.filters import NumericInFilter
 from utilities.filters import NumericInFilter
 
 
 
 
+class SecretRoleFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = SecretRole
+        fields = ['name', 'slug']
+
+
 class SecretFilter(django_filters.FilterSet):
 class SecretFilter(django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -16,7 +23,6 @@ class SecretFilter(django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
-        name='role',
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         label='Role (ID)',
         label='Role (ID)',
     )
     )
@@ -27,7 +33,6 @@ class SecretFilter(django_filters.FilterSet):
         label='Role (slug)',
         label='Role (slug)',
     )
     )
     device_id = django_filters.ModelMultipleChoiceFilter(
     device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         label='Device (ID)',
         label='Device (ID)',
     )
     )

+ 1 - 9
netbox/secrets/tables.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 import django_tables2 as tables
 import django_tables2 as tables
 
 
-from utilities.tables import BaseTable, SearchTable, ToggleColumn
+from utilities.tables import BaseTable, ToggleColumn
 
 
 from .models import SecretRole, Secret
 from .models import SecretRole, Secret
 
 
@@ -43,11 +43,3 @@ class SecretTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Secret
         model = Secret
         fields = ('pk', 'device', 'role', 'name', 'last_updated')
         fields = ('pk', 'device', 'role', 'name', 'last_updated')
-
-
-class SecretSearchTable(SearchTable):
-    device = tables.LinkColumn()
-
-    class Meta(SearchTable.Meta):
-        model = Secret
-        fields = ('device', 'role', 'name', 'last_updated')

+ 6 - 2
netbox/secrets/views.py

@@ -4,7 +4,6 @@ import base64
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required, login_required
 from django.contrib.auth.decorators import permission_required, login_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.db import transaction, IntegrityError
 from django.db.models import Count
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -56,6 +55,8 @@ class SecretRoleEditView(SecretRoleCreateView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secretrole'
     permission_required = 'secrets.delete_secretrole'
     cls = SecretRole
     cls = SecretRole
+    queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
+    table = tables.SecretRoleTable
     default_return_url = 'secrets:secretrole_list'
     default_return_url = 'secrets:secretrole_list'
 
 
 
 
@@ -240,14 +241,17 @@ class SecretBulkImportView(BulkImportView):
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
 class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'secrets.change_secret'
     permission_required = 'secrets.change_secret'
     cls = Secret
     cls = Secret
+    queryset = Secret.objects.select_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
+    table = tables.SecretTable
     form = forms.SecretBulkEditForm
     form = forms.SecretBulkEditForm
-    template_name = 'secrets/secret_bulk_edit.html'
     default_return_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
+    queryset = Secret.objects.select_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
+    table = tables.SecretTable
     default_return_url = 'secrets:secret_list'
     default_return_url = 'secrets:secret_list'

+ 11 - 5
netbox/templates/_base.html

@@ -3,7 +3,7 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="en">
 <html lang="en">
 <head>
 <head>
-	<title>NetBox - {% block title %}Home{% endblock %}</title>
+	<title>{% block title %}Home{% endblock %} - NetBox</title>
 	<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
 	<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
@@ -323,13 +323,19 @@
             </div>
             </div>
         </div>
         </div>
     </footer>
     </footer>
-<script type="text/javascript">
-    var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
-</script>
-<script src="{% static 'js/jquery-3.2.0.min.js' %}"></script>
+<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
+<script type="text/javascript">
+    var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
+    var loading = $(".loading");
+    $(document).ajaxStart(function() {
+        loading.show();
+    }).ajaxStop(function() {
+        loading.hide();
+    });
+</script>
 {% block javascript %}{% endblock %}
 {% block javascript %}{% endblock %}
 </body>
 </body>
 </html>
 </html>

+ 0 - 23
netbox/templates/circuits/circuit_bulk_edit.html

@@ -1,23 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Circuit Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Circuit</th>
-        <th>Type</th>
-        <th>Provider</th>
-        <th>Port speed</th>
-        <th>Commit rate</th>
-    </tr>
-    {% for circuit in selected_objects %}
-        <tr>
-            <td><a href="{% url 'circuits:circuit' pk=circuit.pk %}">{{ circuit }}</a></td>
-            <td>{{ circuit.type }}</td>
-            <td>{{ circuit.provider }}</td>
-            <td>{{ circuit.port_speed_human }}</td>
-            <td>{{ circuit.commit_rate_human }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 19
netbox/templates/circuits/provider_bulk_edit.html

@@ -1,19 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Provider Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Provider</th>
-        <th>Account</th>
-        <th>ASN</th>
-    </tr>
-    {% for provider in selected_objects %}
-        <tr>
-            <td><a href="{% url 'circuits:provider' slug=provider.slug %}">{{ provider }}</a></td>
-            <td>{{ provider.account }}</td>
-            <td>{{ provider.asn }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 3 - 22
netbox/templates/dcim/device.html

@@ -7,7 +7,7 @@
 {% block content %}
 {% block content %}
 {% include 'dcim/inc/device_header.html' with active_tab='info' %}
 {% include 'dcim/inc/device_header.html' with active_tab='info' %}
 <div class="row">
 <div class="row">
-	<div class="col-md-5 col-lg-6">
+	<div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Device</strong>
                 <strong>Device</strong>
@@ -214,23 +214,9 @@
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
-                <strong>Critical Connections</strong>
+                <strong>Console / Power</strong>
             </div>
             </div>
             <table class="table table-hover panel-body component-list">
             <table class="table table-hover panel-body component-list">
-                {% for iface in mgmt_interfaces %}
-                    {% include 'dcim/inc/interface.html' with icon='wrench' %}
-                {% empty %}
-                    {% if device.device_type.interface_templates.exists %}
-                        <tr>
-                            <td colspan="6" class="alert-warning">
-                                <i class="fa fa-fw fa-warning"></i> No management interfaces defined
-                                {% if perms.dcim.add_interface %}
-                                    <a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
-                                {% endif %}
-                            </td>
-                        </tr>
-                    {% endif %}
-                {% endfor %}
                 {% for cp in console_ports %}
                 {% for cp in console_ports %}
                     {% include 'dcim/inc/consoleport.html' %}
                     {% include 'dcim/inc/consoleport.html' %}
                 {% empty %}
                 {% empty %}
@@ -262,11 +248,6 @@
             </table>
             </table>
             {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
             {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                 <div class="panel-footer text-right">
                 <div class="panel-footer text-right">
-                    {% if perms.dcim.add_interface %}
-                        <a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interface
-                        </a>
-                    {% endif %}
                     {% if perms.dcim.add_consoleport %}
                     {% if perms.dcim.add_consoleport %}
                         <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
                         <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
@@ -333,7 +314,7 @@
             {% endif %}
             {% endif %}
         </div>
         </div>
 	</div>
 	</div>
-	<div class="col-md-7 col-lg-6">
+	<div class="col-md-7">
         {% if device_bays or device.device_type.is_parent_device %}
         {% if device_bays or device.device_type.is_parent_device %}
             {% if perms.dcim.delete_devicebay %}
             {% if perms.dcim.delete_devicebay %}
                 <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
                 <form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">

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

@@ -1,23 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Device Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Device</th>
-        <th>Type</th>
-        <th>Role</th>
-        <th>Tenant</th>
-        <th>Serial</th>
-    </tr>
-    {% for device in selected_objects %}
-        <tr>
-            <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
-            <td>{{ device.device_type.full_name }}</td>
-            <td>{{ device.device_role }}</td>
-            <td>{{ device.tenant }}</td>
-            <td>{{ device.serial }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 53 - 0
netbox/templates/dcim/device_config.html

@@ -0,0 +1,53 @@
+{% extends '_base.html' %}
+{% load staticfiles %}
+
+{% block title %}{{ device }} - Config{% endblock %}
+
+{% block content %}
+    {% include 'inc/ajax_loader.html' %}
+    {% include 'dcim/inc/device_header.html' with active_tab='config' %}
+    <div class="row">
+        <div class="col-md-10 col-md-offset-1">
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>Device Configuration</strong></div>
+                <div class="panel-body">
+                    <ul class="nav nav-tabs" role="tablist">
+                        <li role="presentation" class="active"><a href="#running" aria-controls="running" role="tab" data-toggle="tab">Running</a></li>
+                        <li role="presentation"><a href="#startup" aria-controls="startup" role="tab" data-toggle="tab">Startup</a></li>
+                        <li role="presentation"><a href="#candidate" aria-controls="candidate" role="tab" data-toggle="tab">Candidate</a></li>
+                    </ul>
+                    <div class="tab-content">
+                        <div role="tabpanel" class="tab-pane active" id="running">
+                            <pre id="running_config"></pre>
+                        </div>
+                        <div role="tabpanel" class="tab-pane" id="startup">
+                            <pre id="startup_config"></pre>
+                        </div>
+                        <div role="tabpanel" class="tab-pane" id="candidate">
+                            <pre id="candidate_config"></pre>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+$(document).ready(function() {
+    $.ajax({
+        url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_config",
+        dataType: 'json',
+        success: function(json) {
+            $('#running_config').html($.trim(json['get_config']['running']));
+            $('#startup_config').html($.trim(json['get_config']['startup']));
+            $('#candidate_config').html($.trim(json['get_config']['candidate']));
+        },
+        error: function(xhr) {
+            alert(xhr.responseText);
+        }
+    });
+});
+</script>
+{% endblock %}

+ 2 - 0
netbox/templates/dcim/device_inventory.html

@@ -51,6 +51,8 @@
                         <th>Manufacturer</th>
                         <th>Manufacturer</th>
                         <th>Part Number</th>
                         <th>Part Number</th>
                         <th>Serial Number</th>
                         <th>Serial Number</th>
+                        <th>Asset Tag</th>
+                        <th>Description</th>
                         <th></th>
                         <th></th>
                     </tr>
                     </tr>
                 </thead>
                 </thead>

+ 46 - 44
netbox/templates/dcim/device_lldp_neighbors.html

@@ -3,64 +3,66 @@
 {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
 {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
 
 
 {% block content %}
 {% block content %}
-{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
-<div class="panel panel-default">
-    <div class="panel-heading">
-        <strong>LLDP Neighbors</strong>
-    </div>
-    <table class="table table-hover panel-body">
-        <thead>
-            <tr>
-                <th>Interface</th>
-                <th>Configured Device</th>
-                <th>Configured Interface</th>
-                <th>LLDP Device</th>
-                <th>LLDP Interface</th>
-            </tr>
-        </thead>
-        <tbody>
-            {% for iface in interfaces %}
-                <tr id="{{ iface }}">
-                    <td>{{ iface }}</td>
-                    {% if iface.connection %}
-                        {% with iface.connected_interface as connected_iface %}
-                            <td class="configured_device" data="{{ connected_iface.device }}">
-                                <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
-                            </td>
-                            <td class="configured_interface" data="{{ connected_iface }}">
-                                <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
-                            </td>
-                        {% endwith %}
-                    {% else %}
-                        <td colspan="2">None</td>
-                    {% endif %}
-                    <td class="device"></td>
-                    <td class="interface"></td>
+    {% include 'inc/ajax_loader.html' %}
+    {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
+    <div class="panel panel-default">
+        <div class="panel-heading">
+            <strong>LLDP Neighbors</strong>
+        </div>
+        <table class="table table-hover panel-body">
+            <thead>
+                <tr>
+                    <th>Interface</th>
+                    <th>Configured Device</th>
+                    <th>Configured Interface</th>
+                    <th>LLDP Device</th>
+                    <th>LLDP Interface</th>
                 </tr>
                 </tr>
-            {% endfor %}
-        </tbody>
-    </table>
-</div>
+            </thead>
+            <tbody>
+                {% for iface in interfaces %}
+                    <tr id="{{ iface.name }}">
+                        <td>{{ iface }}</td>
+                        {% if iface.connection %}
+                            {% with iface.connected_interface as connected_iface %}
+                                <td class="configured_device" data="{{ connected_iface.device }}">
+                                    <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
+                                </td>
+                                <td class="configured_interface" data="{{ connected_iface }}">
+                                    <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
+                                </td>
+                            {% endwith %}
+                        {% else %}
+                            <td colspan="2">None</td>
+                        {% endif %}
+                        <td class="device"></td>
+                        <td class="interface"></td>
+                    </tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </div>
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
 <script type="text/javascript">
 <script type="text/javascript">
 $(document).ready(function() {
 $(document).ready(function() {
     $.ajax({
     $.ajax({
-        url: "{% url 'dcim-api:device-lldp-neighbors' pk=device.pk %}",
+        url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors",
         dataType: 'json',
         dataType: 'json',
         success: function(json) {
         success: function(json) {
-            $.each(json, function(i, neighbor) {
-                var row = $('#' + neighbor['local-interface'].replace(/(\/)/g, "\\$1"));
+            $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
+                var neighbor = neighbors[0];
+                var row = $('#' + iface.replace(/(\/)/g, "\\$1"));
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
                 // Add LLDP neighbors to table
                 // Add LLDP neighbors to table
-                row.children('td.device').html(neighbor['name']);
-                row.children('td.interface').html(neighbor['remote-interface']);
+                row.children('td.device').html(neighbor['hostname']);
+                row.children('td.interface').html(neighbor['port']);
                 // Apply colors to rows
                 // Apply colors to rows
-                if (!configured_device && neighbor['name']) {
+                if (!configured_device && neighbor['hostname']) {
                     row.addClass('info');
                     row.addClass('info');
-                } else if (configured_device == neighbor['name'] && configured_interface == neighbor['remote-interface']) {
+                } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port']) {
                     row.addClass('success');
                     row.addClass('success');
                 } else {
                 } else {
                     row.addClass('danger');
                     row.addClass('danger');

+ 125 - 0
netbox/templates/dcim/device_status.html

@@ -0,0 +1,125 @@
+{% extends '_base.html' %}
+{% load staticfiles %}
+
+{% block title %}{{ device }} - Status{% endblock %}
+
+{% block content %}
+    {% include 'inc/ajax_loader.html' %}
+    {% include 'dcim/inc/device_header.html' with active_tab='status' %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>Device Facts</strong></div>
+                <table class="table panel-body">
+                    <tr>
+                        <th>Hostname</th>
+                        <td id="hostname"></td>
+                    </tr>
+                    <tr>
+                        <th>FQDN</th>
+                        <td id="fqdn"></td>
+                    </tr>
+                    <tr>
+                        <th>Vendor</th>
+                        <td id="vendor"></td>
+                    </tr>
+                    <tr>
+                        <th>Model</th>
+                        <td id="model"></td>
+                    </tr>
+                    <tr>
+                        <th>Serial Number</th>
+                        <td id="serial_number"></td>
+                    </tr>
+                    <tr>
+                        <th>OS Version</th>
+                        <td id="os_version"></td>
+                    </tr>
+                    <tr>
+                        <th>Uptime</th>
+                        <td id="uptime"></td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>Environment</strong></div>
+                <table class="table panel-body">
+                    <tr id="cpu">
+                        <th colspan="2"><i class="fa fa-tachometer"></i> CPU</th>
+                    </tr>
+                    <tr id="memory">
+                        <th colspan="2"><i class="fa fa-microchip"></i> Memory</th>
+                    </tr>
+                    <tr id="temperature">
+                        <th colspan="2"><i class="fa fa-thermometer"></i> Temperature</th>
+                    </tr>
+                    <tr id="fans">
+                        <th colspan="2"><i class="fa fa-superpowers"></i> Fans</th>
+                    </tr>
+                    <tr id="power">
+                        <th colspan="2"><i class="fa fa-bolt"></i> Power</th>
+                    </tr>
+                </table>
+            </div>
+        </div>
+    </div>
+{% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+$(document).ready(function() {
+    $.ajax({
+        url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_facts&method=get_environment",
+        dataType: 'json',
+        success: function(json) {
+            $('#hostname').html(json['get_facts']['hostname']);
+            $('#fqdn').html(json['get_facts']['fqdn']);
+            $('#vendor').html(json['get_facts']['vendor']);
+            $('#model').html(json['get_facts']['model']);
+            $('#serial_number').html(json['get_facts']['serial_number']);
+            $('#os_version').html(json['get_facts']['os_version']);
+            $('#uptime').html(json['get_facts']['uptime']);
+            $.each(json['get_environment']['cpu'], function(name, obj) {
+                var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
+                $("#cpu").after(row)
+            });
+            $('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "MB</td></tr>");
+            $('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "MB</td></tr>");
+            $.each(json['get_environment']['temperature'], function(name, obj) {
+                var style = "success";
+                if (obj['is_alert']) {
+                    style = "warning";
+                } else if (obj['is_critical']) {
+                    style = "danger";
+                }
+                var row="<tr class=\"" + style +"\"><td>" + name + "</td><td>" + obj['temperature'] + "°C</td></tr>";
+                $("#temperature").after(row)
+            });
+            $.each(json['get_environment']['fans'], function(name, obj) {
+                var row;
+                if (obj['status']) {
+                    row="<tr class=\"success\"><td>" + name + "</td><td><i class=\"fa fa-check text-success\"></i></td></tr>";
+                } else {
+                    row="<tr class=\"error\"><td>" + name + "</td><td><i class=\"fa fa-close text-error\"></i></td></tr>";
+                }
+                $("#fans").after(row)
+            });
+            $.each(json['get_environment']['power'], function(name, obj) {
+                var row;
+                if (obj['status']) {
+                    row="<tr class=\"success\"><td>" + name + "</td><td><i class=\"fa fa-check text-success\"></i></td></tr>";
+                } else {
+                    row="<tr class=\"danger\"><td>" + name + "</td><td><i class=\"fa fa-close text-danger\"></i></td></tr>";
+                }
+                $("#power").after(row)
+            });
+        },
+        error: function(xhr) {
+            alert(xhr.responseText);
+        }
+    });
+});
+</script>
+{% endblock %}

+ 7 - 8
netbox/templates/dcim/devicetype.html

@@ -33,7 +33,7 @@
 
 
 <h1>{{ devicetype.manufacturer }} {{ devicetype.model }}</h1>
 <h1>{{ devicetype.manufacturer }} {{ devicetype.model }}</h1>
 <div class="row">
 <div class="row">
-    <div class="col-md-6">
+    <div class="col-md-5">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Chassis</strong>
                 <strong>Chassis</strong>
@@ -163,21 +163,20 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+    </div>
+    <div class="col-md-7">
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
         {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
-        {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
-    </div>
-    <div class="col-md-6">
-        {% if devicetype.is_parent_device %}
+        {% if devicetype.is_parent_device or devicebay_table.rows %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
         {% endif %}
         {% endif %}
-        {% if devicetype.is_network_device %}
+        {% if devicetype.is_network_device or interface_table.rows %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
         {% endif %}
         {% endif %}
-        {% if devicetype.is_console_server %}
+        {% if devicetype.is_console_server or consoleserverport_table.rows %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
         {% endif %}
         {% endif %}
-        {% if devicetype.is_pdu %}
+        {% if devicetype.is_pdu or poweroutlet_table.rows %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
             {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
         {% endif %}
         {% endif %}
     </div>
     </div>

+ 0 - 19
netbox/templates/dcim/devicetype_bulk_edit.html

@@ -1,19 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Device Type Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Device type</th>
-        <th>Manufacturer</th>
-        <th>Height</th>
-    </tr>
-    {% for devicetype in selected_objects %}
-        <tr>
-            <td><a href="{% url 'dcim:devicetype' pk=devicetype.pk %}">{{ devicetype.model }}</a></td>
-            <td>{{ devicetype.manufacturer }}</td>
-            <td>{{ devicetype.u_height }}U</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

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

@@ -1,13 +1,7 @@
 <tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
 <tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
-    {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
-        <td class="pk">
-            <input name="pk" type="checkbox" value="{{ cp.pk }}" />
-        </td>
-    {% endif %}
     <td>
     <td>
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
     </td>
     </td>
-    <td></td>
     {% if cp.cs_port %}
     {% if cp.cs_port %}
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
             <a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
@@ -20,7 +14,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td colspan="2" class="text-right">
+    <td class="text-right">
         {% if perms.dcim.change_consoleport %}
         {% if perms.dcim.change_consoleport %}
             {% if cp.cs_port %}
             {% if cp.cs_port %}
                 {% if cp.connection_status %}
                 {% if cp.connection_status %}

+ 10 - 2
netbox/templates/dcim/inc/device_header.html

@@ -45,7 +45,15 @@
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
     <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
     <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
     <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
-    {% if device.status == 1 and device.platform.rpc_client and device.primary_ip %}
-        <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
+    {% if perms.dcim.napalm_read %}
+        {% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
+            <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
+            <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
+            <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
+        {% else %}
+            <li role="presentation" class="disabled"><a href="#">Status</a></li>
+            <li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
+            <li role="presentation" class="disabled"><a href="#">Configuration</a></li>
+        {% endif %}
     {% endif %}
     {% endif %}
 </ul>
 </ul>

+ 7 - 3
netbox/templates/dcim/inc/interface.html

@@ -1,11 +1,12 @@
-<tr class="interface{% if iface.connection and iface.connection.connection_status %} success{% elif iface.connection and not iface.connection.connection_status %} info{% endif %}">
+<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status %} success{% elif iface.connection and not iface.connection.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
     {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
         </td>
         </td>
     {% endif %}
     {% endif %}
     <td>
     <td>
-        <i class="fa fa-fw fa-{{ icon|default:"exchange" }}"></i> <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span>
+        <i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_virtual %}square{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
+        <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span>
         {% if iface.lag %}
         {% if iface.lag %}
             <span class="label label-primary">{{ iface.lag.name }}</span>
             <span class="label label-primary">{{ iface.lag.name }}</span>
         {% endif %}
         {% endif %}
@@ -13,6 +14,7 @@
             <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
             <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
         {% endif %}
         {% endif %}
     </td>
     </td>
+    <td>{{ iface.mtu|default:"" }}</td>
     <td>{{ iface.mac_address|default:"" }}</td>
     <td>{{ iface.mac_address|default:"" }}</td>
     {% if iface.is_lag %}
     {% if iface.is_lag %}
         <td colspan="2" class="text-muted">
         <td colspan="2" class="text-muted">
@@ -21,6 +23,8 @@
         </td>
         </td>
     {% elif iface.is_virtual %}
     {% elif iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
+    {% elif iface.is_wireless %}
+        <td colspan="2" class="text-muted">Wireless interface</td>
     {% elif iface.connection %}
     {% elif iface.connection %}
         {% with iface.connected_interface as connected_iface %}
         {% with iface.connected_interface as connected_iface %}
             <td>
             <td>
@@ -114,7 +118,7 @@
         {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
         {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
             <td></td>
             <td></td>
         {% endif %}
         {% endif %}
-        <td colspan="2">
+        <td colspan="3">
             <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
             <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
             {% if ip.description %}
             {% if ip.description %}
                 <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
                 <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>

+ 3 - 1
netbox/templates/dcim/inc/inventoryitem.html

@@ -1,9 +1,11 @@
 <tr>
 <tr>
     <td style="padding-left: {{ indent|add:5 }}px">{{ item.name }}</td>
     <td style="padding-left: {{ indent|add:5 }}px">{{ item.name }}</td>
     <td>{% if not item.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
     <td>{% if not item.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
-    <td>{{ item.manufacturer|default:'' }}</td>
+    <td>{{ item.manufacturer|default:"" }}</td>
     <td>{{ item.part_id }}</td>
     <td>{{ item.part_id }}</td>
     <td>{{ item.serial }}</td>
     <td>{{ item.serial }}</td>
+    <td>{{ item.asset_tag|default:"" }}</td>
+    <td>{{ item.description }}</td>
     <td class="text-right">
     <td class="text-right">
         {% if perms.dcim.change_inventoryitem %}
         {% if perms.dcim.change_inventoryitem %}
             <a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
             <a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>

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

@@ -1,13 +1,7 @@
 <tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
 <tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
-    {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
-        <td class="pk">
-            <input name="pk" type="checkbox" value="{{ pp.pk }}" />
-        </td>
-    {% endif %}
     <td>
     <td>
         <i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
         <i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
     </td>
     </td>
-    <td></td>
     {% if pp.power_outlet %}
     {% if pp.power_outlet %}
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
             <a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
@@ -20,7 +14,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td colspan="2" class="text-right">
+    <td class="text-right">
         {% if perms.dcim.change_powerport %}
         {% if perms.dcim.change_powerport %}
             {% if pp.power_outlet %}
             {% if pp.power_outlet %}
                 {% if pp.connection_status %}
                 {% if pp.connection_status %}

+ 0 - 17
netbox/templates/dcim/interface_bulk_edit.html

@@ -1,17 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Interface Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Name</th>
-        <th>Form Factor</th>
-    </tr>
-    {% for iface in selected_objects %}
-        <tr>
-            <td>{{ iface.name }}</td>
-            <td>{{ iface.get_form_factor_display }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 25
netbox/templates/dcim/interfacetemplate_bulk_edit.html

@@ -1,25 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Interface Template Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Name</th>
-        <th>Form Factor</th>
-        <th>Management</th>
-    </tr>
-    {% for iface in selected_objects %}
-        <tr>
-            <td>{{ iface.name }}</td>
-            <td>{{ iface.get_form_factor_display }}</td>
-            <td>
-                {% if iface.mgmt_only %}
-                    <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
-                {% else %}
-                    <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
-                {% endif %}
-            </td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 29
netbox/templates/dcim/rack_bulk_edit.html

@@ -1,29 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Rack Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Name</th>
-        <th>Site</th>
-        <th>Group</th>
-        <th>Tenant</th>
-        <th>Role</th>
-        <th>Type</th>
-        <th>Width</th>
-        <th>Height</th>
-    </tr>
-    {% for rack in selected_objects %}
-        <tr>
-            <td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
-            <td>{{ rack.site }}</td>
-            <td>{{ rack.group }}</td>
-            <td>{{ rack.tenant }}</td>
-            <td>{{ rack.role }}</td>
-            <td>{{ rack.get_type_display }}</td>
-            <td>{{ rack.get_width_display }}</td>
-            <td>{{ rack.u_height }}U</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/site_bulk_edit.html

@@ -1,17 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Site Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Site</th>
-        <th>Tenant</th>
-    </tr>
-    {% for site in selected_objects %}
-        <tr>
-            <td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site }}</a></td>
-            <td>{{ site.tenant }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 4 - 0
netbox/templates/inc/ajax_loader.html

@@ -0,0 +1,4 @@
+{% load staticfiles %}
+<div class="loading text-center">
+    <img src="{% static 'img/ajax-loader.gif' %}" />
+</div>

+ 0 - 21
netbox/templates/ipam/aggregate_bulk_edit.html

@@ -1,21 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Aggregate Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Aggregate</th>
-        <th>RIR</th>
-        <th>Date Added</th>
-        <th>Description</th>
-    </tr>
-    {% for aggregate in selected_objects %}
-        <tr>
-            <td><a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate }}</a></td>
-            <td>{{ aggregate.rir }}</td>
-            <td>{{ aggregate.date_added }}</td>
-            <td>{{ aggregate.description }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

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

@@ -82,6 +82,12 @@
                         <span class="label label-{{ ipaddress.get_status_class }}">{{ ipaddress.get_status_display }}</span>
                         <span class="label label-{{ ipaddress.get_status_class }}">{{ ipaddress.get_status_display }}</span>
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Role</td>
+                    <td>
+                        <a href="{% url 'ipam:ipaddress_list' %}?role={{ ipaddress.role }}">{{ ipaddress.get_role_display }}</a>
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Description</td>
                     <td>Description</td>
                     <td>
                     <td>

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

@@ -14,6 +14,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field pattern_form.pattern %}
             {% render_field pattern_form.pattern %}
             {% render_field model_form.status %}
             {% render_field model_form.status %}
+            {% render_field model_form.role %}
             {% render_field model_form.vrf %}
             {% render_field model_form.vrf %}
             {% render_field model_form.description %}
             {% render_field model_form.description %}
         </div>
         </div>

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

@@ -1,25 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}IP Address Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>IP Address</th>
-        <th>VRF</th>
-        <th>Tenant</th>
-        <th>Status</th>
-        <th>Assigned</th>
-        <th>Description</th>
-    </tr>
-    {% for ipaddress in selected_objects %}
-        <tr>
-            <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
-            <td>{{ ipaddress.vrf|default:"Global" }}</td>
-            <td>{{ ipaddress.tenant }}</td>
-            <td>{{ ipaddress.get_status_display }}</td>
-            <td>{% if ipaddress.interface %}<i class="glyphicon glyphicon-ok text-success" title="{{ ipaddress.interface.device }} {{ ipaddress.interface }}"></i>{% endif %}</td>
-            <td>{{ ipaddress.description }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

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

@@ -14,6 +14,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.address %}
             {% render_field form.address %}
             {% render_field form.status %}
             {% render_field form.status %}
+            {% render_field form.role %}
             {% render_field form.vrf %}
             {% render_field form.vrf %}
             {% render_field form.description %}
             {% render_field form.description %}
         </div>
         </div>

+ 0 - 25
netbox/templates/ipam/prefix_bulk_edit.html

@@ -1,25 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Prefix Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Prefix</th>
-        <th>Site</th>
-        <th>VRF</th>
-        <th>Tenant</th>
-        <th>Status</th>
-        <th>Role</th>
-    </tr>
-    {% for prefix in selected_objects %}
-        <tr>
-            <td><a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a></td>
-            <td>{{ prefix.site }}</td>
-            <td>{{ prefix.vrf|default:"Global" }}</td>
-            <td>{{ prefix.tenant }}</td>
-            <td>{{ prefix.get_status_display }}</td>
-            <td>{{ prefix.role }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 25
netbox/templates/ipam/vlan_bulk_edit.html

@@ -1,25 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}VLAN Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>VLAN</th>
-        <th>Site</th>
-        <th>Group</th>
-        <th>Tenant</th>
-        <th>Status</th>
-        <th>Role</th>
-    </tr>
-    {% for vlan in selected_objects %}
-        <tr>
-            <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan }}</a></td>
-            <td>{{ vlan.site }}</td>
-            <td>{{ vlan.group }}</td>
-            <td>{{ vlan.tenant }}</td>
-            <td>{{ vlan.get_status_display }}</td>
-            <td>{{ vlan.role }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 21
netbox/templates/ipam/vrf_bulk_edit.html

@@ -1,21 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}VRF Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>VRF</th>
-        <th>RD</th>
-        <th>Tenant</th>
-        <th>Description</th>
-    </tr>
-    {% for vrf in selected_objects %}
-        <tr>
-            <td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>
-            <td>{{ vrf.rd }}</td>
-            <td>{{ vrf.tenant }}</td>
-            <td>{{ vrf.description }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 19
netbox/templates/secrets/secret_bulk_edit.html

@@ -1,19 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Secret Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Device</th>
-        <th>Role</th>
-        <th>Name</th>
-    </tr>
-    {% for secret in selected_objects %}
-        <tr>
-            <td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret.device }}</a></td>
-            <td>{{ secret.role }}</td>
-            <td>{{ secret.name }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 17
netbox/templates/tenancy/tenant_bulk_edit.html

@@ -1,17 +0,0 @@
-{% extends 'utilities/bulk_edit_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Tenant Bulk Edit{% endblock %}
-
-{% block selected_objects_table %}
-    <tr>
-        <th>Tenant</th>
-        <th>Group</th>
-    </tr>
-    {% for tenant in selected_objects %}
-        <tr>
-            <td><a href="{% url 'tenancy:tenant' slug=tenant.slug %}">{{ tenant }}</a></td>
-            <td>{{ tenant.group }}</td>
-        </tr>
-    {% endfor %}
-{% endblock %}

+ 0 - 19
netbox/templates/utilities/confirm_bulk_delete.html

@@ -1,19 +0,0 @@
-{% extends 'utilities/confirmation_form.html' %}
-{% load form_helpers %}
-
-{% block title %}Delete {{ obj_type_plural|default:"objects" }}?{% endblock %}
-
-{% block message %}
-    <p>
-        Are you sure you want to delete these {{ selected_objects|length }} {{ obj_type_plural|default:"objects" }}{% if parent_obj %} from <a href="{{ parent_obj.get_absolute_url }}">{{ parent_obj }}</a>{% endif %}?
-    </p>
-    <ul>
-        {% for obj in selected_objects %}
-            {% if obj.get_absolute_url %}
-                <li><a href="{{ obj.get_absolute_url }}">{{ obj }}</a></li>
-            {% else %}
-                <li>{{ obj }}</li>
-            {% endif %}
-        {% endfor %}
-    </ul>
-{% endblock %}

+ 4 - 12
netbox/templates/utilities/confirmation_form.html

@@ -5,22 +5,14 @@
 <div class="row">
 <div class="row">
 	<div class="col-md-6 col-md-offset-3">
 	<div class="col-md-6 col-md-offset-3">
         <form action="." method="post" class="form">
         <form action="." method="post" class="form">
-        {% csrf_token %}
-        {% for field in form.hidden_fields %}
-            {{ field }}
-        {% endfor %}
+            {% csrf_token %}
+            {% for field in form.hidden_fields %}
+                {{ field }}
+            {% endfor %}
             <div class="panel panel-{{ panel_class|default:"danger" }}">
             <div class="panel panel-{{ panel_class|default:"danger" }}">
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-body">
                 <div class="panel-body">
                     {% block message %}<p>Are you sure?</p>{% endblock %}
                     {% block message %}<p>Are you sure?</p>{% endblock %}
-                    <div class="form-group">
-                        <div class="checkbox{% if form.confirm.errors %} has-error{% endif %}">
-                            <label for="{{ form.confirm.id_for_label }}">
-                                {{ form.confirm }}
-                                {{ form.confirm.label }}
-                            </label>
-                        </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="{{ return_url }}" class="btn btn-default">Cancel</a>
                         <a href="{{ return_url }}" class="btn btn-default">Cancel</a>

+ 38 - 0
netbox/templates/utilities/obj_bulk_delete.html

@@ -0,0 +1,38 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Delete {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="panel panel-danger">
+                <div class="panel-heading"><strong>Confirm Bulk Deletion</strong></div>
+                <div class="panel-body">
+                    <strong>Warning:</strong> The following operation will delete {{ table.rows|length }} {{ obj_type_plural }}. Please carefully review the {{ obj_type_plural }} to be deleted and confirm below.
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="panel panel-default">
+                {% include 'inc/table.html' %}
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            <form action="." method="post" class="form">
+                {% csrf_token %}
+                {% for field in form.hidden_fields %}
+                    {{ field }}
+                {% endfor %}
+                <div class="text-center">
+                    <button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                </div>
+            </form>
+        </div>
+    </div>
+{% endblock %}

+ 5 - 7
netbox/templates/utilities/bulk_edit_form.html → netbox/templates/utilities/obj_bulk_edit.html

@@ -1,8 +1,9 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
-<h1>{% block title %}{% endblock %}</h1>
+<h1>{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% 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.return_url %}
     {% if request.POST.return_url %}
@@ -12,15 +13,12 @@
         {{ field }}
         {{ field }}
     {% endfor %}
     {% endfor %}
     <div class="row">
     <div class="row">
-        <div class="col-md-7">
+        <div class="col-md-8">
             <div class="panel panel-default">
             <div class="panel panel-default">
-                <div class="panel-heading"><strong>{% block selected_objects_title %}{{ selected_objects|length }} Selected For Editing{% endblock %}</strong></div>
-                <table class="panel-body table table-hover">
-                    {% block selected_objects_table %}{% endblock %}
-                </table>
+                {% include 'inc/table.html' %}
             </div>
             </div>
         </div>
         </div>
-        <div class="col-md-5">
+        <div class="col-md-4">
             {% if form.non_field_errors %}
             {% if form.non_field_errors %}
                 <div class="panel panel-danger">
                 <div class="panel panel-danger">
                     <div class="panel-heading"><strong>Errors</strong></div>
                     <div class="panel-heading"><strong>Errors</strong></div>

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

@@ -4,13 +4,14 @@ from rest_framework import serializers
 
 
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.api import ModelValidationMixin
 
 
 
 
 #
 #
 # Tenant groups
 # Tenant groups
 #
 #
 
 
-class TenantGroupSerializer(serializers.ModelSerializer):
+class TenantGroupSerializer(ModelValidationMixin, serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup

+ 3 - 2
netbox/tenancy/api/views.py

@@ -3,8 +3,8 @@ from __future__ import unicode_literals
 from rest_framework.viewsets import ModelViewSet
 from rest_framework.viewsets import ModelViewSet
 
 
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
+from tenancy import filters
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from tenancy.filters import TenantFilter
 from utilities.api import WritableSerializerMixin
 from utilities.api import WritableSerializerMixin
 from . import serializers
 from . import serializers
 
 
@@ -16,6 +16,7 @@ from . import serializers
 class TenantGroupViewSet(ModelViewSet):
 class TenantGroupViewSet(ModelViewSet):
     queryset = TenantGroup.objects.all()
     queryset = TenantGroup.objects.all()
     serializer_class = serializers.TenantGroupSerializer
     serializer_class = serializers.TenantGroupSerializer
+    filter_class = filters.TenantGroupFilter
 
 
 
 
 #
 #
@@ -26,4 +27,4 @@ class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = Tenant.objects.select_related('group')
     queryset = Tenant.objects.select_related('group')
     serializer_class = serializers.TenantSerializer
     serializer_class = serializers.TenantSerializer
     write_serializer_class = serializers.WritableTenantSerializer
     write_serializer_class = serializers.WritableTenantSerializer
-    filter_class = TenantFilter
+    filter_class = filters.TenantFilter

+ 7 - 1
netbox/tenancy/filters.py

@@ -9,6 +9,13 @@ from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
 
 
+class TenantGroupFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = TenantGroup
+        fields = ['name', 'slug']
+
+
 class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
 class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -16,7 +23,6 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     group_id = NullableModelMultipleChoiceFilter(
     group_id = NullableModelMultipleChoiceFilter(
-        name='group',
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         label='Group (ID)',
         label='Group (ID)',
     )
     )

+ 1 - 1
netbox/tenancy/forms.py

@@ -102,7 +102,7 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
         # Initialize helper selector
         # Initialize helper selector
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
         if instance and instance.tenant is not None:
         if instance and instance.tenant is not None:
-            initial = kwargs.get('initial', {})
+            initial = kwargs.get('initial', {}).copy()
             initial['tenant_group'] = instance.tenant.group
             initial['tenant_group'] = instance.tenant.group
             kwargs['initial'] = initial
             kwargs['initial'] = initial
 
 

+ 1 - 9
netbox/tenancy/tables.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 
 import django_tables2 as tables
 import django_tables2 as tables
 
 
-from utilities.tables import BaseTable, SearchTable, ToggleColumn
+from utilities.tables import BaseTable, ToggleColumn
 
 
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
@@ -43,11 +43,3 @@ class TenantTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tenant
         model = Tenant
         fields = ('pk', 'name', 'group', 'description')
         fields = ('pk', 'name', 'group', 'description')
-
-
-class TenantSearchTable(SearchTable):
-    name = tables.LinkColumn()
-
-    class Meta(SearchTable.Meta):
-        model = Tenant
-        fields = ('name', 'group', 'description')

+ 6 - 1
netbox/tenancy/views.py

@@ -42,6 +42,8 @@ class TenantGroupEditView(TenantGroupCreateView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenantgroup'
     permission_required = 'tenancy.delete_tenantgroup'
     cls = TenantGroup
     cls = TenantGroup
+    queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
+    table = tables.TenantGroupTable
     default_return_url = 'tenancy:tenantgroup_list'
     default_return_url = 'tenancy:tenantgroup_list'
 
 
 
 
@@ -113,14 +115,17 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
 class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'tenancy.change_tenant'
     permission_required = 'tenancy.change_tenant'
     cls = Tenant
     cls = Tenant
+    queryset = Tenant.objects.select_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
+    table = tables.TenantTable
     form = forms.TenantBulkEditForm
     form = forms.TenantBulkEditForm
-    template_name = 'tenancy/tenant_bulk_edit.html'
     default_return_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
+    queryset = Tenant.objects.select_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
+    table = tables.TenantTable
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'

+ 11 - 0
netbox/utilities/api.py

@@ -98,6 +98,17 @@ class ContentTypeFieldSerializer(Field):
             raise ValidationError("Invalid content type")
             raise ValidationError("Invalid content type")
 
 
 
 
+class ModelValidationMixin(object):
+    """
+    Enforce a model's validation through clean() when validating serializer data. This is necessary to ensure we're
+    employing the same validation logic via both forms and the API.
+    """
+    def validate(self, attrs):
+        instance = self.Meta.model(**attrs)
+        instance.clean()
+        return attrs
+
+
 class WritableSerializerMixin(object):
 class WritableSerializerMixin(object):
     """
     """
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
     Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).

+ 1 - 1
netbox/utilities/forms.py

@@ -510,7 +510,7 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
     """
     """
     A generic confirmation form. The form is not valid unless the confirm field is checked.
     A generic confirmation form. The form is not valid unless the confirm field is checked.
     """
     """
-    confirm = forms.BooleanField(required=True)
+    confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
 
 
 
 
 class BulkEditForm(forms.Form):
 class BulkEditForm(forms.Form):

+ 0 - 11
netbox/utilities/tables.py

@@ -16,21 +16,10 @@ class BaseTable(tables.Table):
         if self.empty_text is None:
         if self.empty_text is None:
             self.empty_text = 'No {} found.'.format(self._meta.model._meta.verbose_name_plural)
             self.empty_text = 'No {} found.'.format(self._meta.model._meta.verbose_name_plural)
 
 
-    class Meta:
-        attrs = {
-            'class': 'table table-hover',
-        }
-
-
-class SearchTable(tables.Table):
-    """
-    Default table for search results
-    """
     class Meta:
     class Meta:
         attrs = {
         attrs = {
             'class': 'table table-hover table-headings',
             'class': 'table table-hover table-headings',
         }
         }
-        orderable = False
 
 
 
 
 class ToggleColumn(tables.CheckBoxColumn):
 class ToggleColumn(tables.CheckBoxColumn):

+ 21 - 8
netbox/utilities/views.py

@@ -461,7 +461,9 @@ 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)
+    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
+    table: The table used to display devices being edited
     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_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by
     default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by
@@ -469,9 +471,11 @@ class BulkEditView(View):
     """
     """
     cls = None
     cls = None
     parent_cls = None
     parent_cls = None
+    queryset = None
     filter = None
     filter = None
+    table = None
     form = None
     form = None
-    template_name = None
+    template_name = 'utilities/obj_bulk_edit.html'
     default_return_url = 'home'
     default_return_url = 'home'
 
 
     def get(self):
     def get(self):
@@ -537,14 +541,17 @@ class BulkEditView(View):
             initial_data['pk'] = pk_list
             initial_data['pk'] = pk_list
             form = self.form(self.cls, initial=initial_data)
             form = self.form(self.cls, initial=initial_data)
 
 
-        selected_objects = self.cls.objects.filter(pk__in=pk_list)
-        if not selected_objects:
+        # Retrieve objects being edited
+        queryset = self.queryset or self.cls.objects.all()
+        table = self.table(queryset.filter(pk__in=pk_list), orderable=False)
+        if not table.rows:
             messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural))
             messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural))
             return redirect(return_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,
+            'table': table,
+            'obj_type_plural': self.cls._meta.verbose_name_plural,
             'return_url': return_url,
             'return_url': return_url,
         })
         })
 
 
@@ -602,7 +609,9 @@ 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)
+    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
+    table: The table used to display devices being deleted
     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_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by
     default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by
@@ -610,9 +619,11 @@ class BulkDeleteView(View):
     """
     """
     cls = None
     cls = None
     parent_cls = None
     parent_cls = None
+    queryset = None
     filter = None
     filter = None
+    table = None
     form = None
     form = None
-    template_name = 'utilities/confirm_bulk_delete.html'
+    template_name = 'utilities/obj_bulk_delete.html'
     default_return_url = 'home'
     default_return_url = 'home'
 
 
     def post(self, request, **kwargs):
     def post(self, request, **kwargs):
@@ -660,8 +671,10 @@ class BulkDeleteView(View):
         else:
         else:
             form = form_cls(initial={'pk': pk_list, 'return_url': return_url})
             form = form_cls(initial={'pk': pk_list, 'return_url': return_url})
 
 
-        selected_objects = self.cls.objects.filter(pk__in=pk_list)
-        if not selected_objects:
+        # Retrieve objects being deleted
+        queryset = self.queryset or self.cls.objects.all()
+        table = self.table(queryset.filter(pk__in=pk_list), orderable=False)
+        if not table.rows:
             messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
             messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural))
             return redirect(return_url)
             return redirect(return_url)
 
 
@@ -669,7 +682,7 @@ class BulkDeleteView(View):
             '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,
+            'table': table,
             'return_url': return_url,
             'return_url': return_url,
         })
         })
 
 

Некоторые файлы не были показаны из-за большого количества измененных файлов