Browse Source

Merge pull request #1852 from digitalocean/develop

Release v2.2.9
Jeremy Stretch 7 years ago
parent
commit
6436d703f5
41 changed files with 368 additions and 122 deletions
  1. 4 3
      docs/api/examples.md
  2. 1 1
      docs/api/overview.md
  3. 1 1
      docs/installation/ldap.md
  4. 7 0
      docs/installation/netbox.md
  5. 1 1
      netbox/circuits/forms.py
  6. 2 1
      netbox/circuits/tables.py
  7. 2 0
      netbox/dcim/api/serializers.py
  8. 30 1
      netbox/dcim/filters.py
  9. 57 8
      netbox/dcim/forms.py
  10. 15 0
      netbox/dcim/models.py
  11. 29 10
      netbox/dcim/tables.py
  12. 5 1
      netbox/dcim/urls.py
  13. 46 1
      netbox/dcim/views.py
  14. 16 13
      netbox/ipam/forms.py
  15. 12 4
      netbox/ipam/models.py
  16. 8 13
      netbox/ipam/tables.py
  17. 3 3
      netbox/ipam/views.py
  18. 1 1
      netbox/netbox/settings.py
  19. 2 1
      netbox/netbox/views.py
  20. 7 6
      netbox/templates/dcim/device_inventory.html
  21. 4 1
      netbox/templates/dcim/device_lldp_neighbors.html
  22. 2 2
      netbox/templates/dcim/inc/interface.html
  23. 1 1
      netbox/templates/dcim/inc/inventoryitem.html
  24. 5 0
      netbox/templates/dcim/inventoryitem_bulk_delete.html
  25. 23 0
      netbox/templates/dcim/inventoryitem_list.html
  26. 8 16
      netbox/templates/dcim/rack.html
  27. 4 1
      netbox/templates/dcim/region_list.html
  28. 2 2
      netbox/templates/extras/inc/report_label.html
  29. 1 1
      netbox/templates/extras/report.html
  30. 1 1
      netbox/templates/extras/report_list.html
  31. 15 0
      netbox/templates/home.html
  32. 11 1
      netbox/templates/inc/nav_menu.html
  33. 1 1
      netbox/templates/ipam/prefix_prefixes.html
  34. 2 1
      netbox/templates/utilities/obj_bulk_delete.html
  35. 1 1
      netbox/tenancy/forms.py
  36. 8 0
      netbox/tenancy/tables.py
  37. 1 1
      netbox/utilities/filters.py
  38. 17 12
      netbox/utilities/forms.py
  39. 1 1
      netbox/utilities/templatetags/helpers.py
  40. 8 8
      netbox/virtualization/forms.py
  41. 3 2
      netbox/virtualization/tables.py

+ 4 - 3
docs/api/examples.md

@@ -82,15 +82,15 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6
 
 ### Creating a new site
 
-Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required.
+Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. This example includes one non required field, "region."
 
 ```
-$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}'
+$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site", "region": 5}'
 {
     "id": 16,
     "name": "My New Site",
     "slug": "my-new-site",
-    "region": null,
+    "region": 5,
     "tenant": null,
     "facility": "",
     "asn": null,
@@ -102,6 +102,7 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
     "comments": ""
 }
 ```
+Note that in this example we are creating a site bound to a region with the ID of 5. For write API actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action.
 
 ### Modify an existing site
 

+ 1 - 1
docs/api/overview.md

@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
 }
 ```
 
-Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name.
+Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.  
 
 When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
 

+ 1 - 1
docs/installation/ldap.md

@@ -81,7 +81,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
 
 # User Groups for Permissions
 !!! info
-    When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
+    When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
 
 ```python
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

+ 7 - 0
docs/installation/netbox.md

@@ -88,6 +88,13 @@ Resolving deltas: 100% (1495/1495), done.
 Checking connectivity... done.
 ```
 
+!!! warning
+    Ensure that the media directory (`/opt/netbox/netbox/media/` in this example) and all its subdirectories are writable by the user account as which NetBox runs. If the NetBox process does not have permission to write to this directory, attempts to upload files (e.g. image attachments) will fail. (The appropriate user account will vary by platform.)
+
+    ```
+    # chown -R netbox:netbox /opt/netbox/netbox/media/
+    ```
+
 ## Install Python Packages
 
 Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)

+ 1 - 1
netbox/circuits/forms.py

@@ -174,7 +174,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),

+ 2 - 1
netbox/circuits/tables.py

@@ -4,6 +4,7 @@ import django_tables2 as tables
 from django.utils.safestring import mark_safe
 from django_tables2.utils import Accessor
 
+from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 
@@ -75,7 +76,7 @@ class CircuitTable(BaseTable):
     pk = ToggleColumn()
     cid = tables.LinkColumn(verbose_name='ID')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
     termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
 

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

@@ -733,6 +733,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
 
 
 class WritableInventoryItemSerializer(ValidatedModelSerializer):
+    # Provide a default value to satisfy UniqueTogetherValidator
+    parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
 
     class Meta:
         model = InventoryItem

+ 30 - 1
netbox/dcim/filters.py

@@ -22,6 +22,10 @@ from .models import (
 
 
 class RegionFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Parent region (ID)',
@@ -37,6 +41,15 @@ class RegionFilter(django_filters.FilterSet):
         model = Region
         fields = ['name', 'slug']
 
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(slug__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
 
 class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
@@ -600,6 +613,10 @@ class DeviceBayFilter(DeviceComponentFilterSet):
 
 
 class InventoryItemFilter(DeviceComponentFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',
@@ -618,7 +635,19 @@ class InventoryItemFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = InventoryItem
-        fields = ['name', 'part_id', 'serial', 'discovered']
+        fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(part_id__icontains=value) |
+            Q(serial__iexact=value) |
+            Q(asset_tag__iexact=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
 
 
 class ConsoleConnectionFilter(django_filters.FilterSet):

+ 57 - 8
netbox/dcim/forms.py

@@ -81,6 +81,11 @@ class RegionCSVForm(forms.ModelForm):
         }
 
 
+class RegionFilterForm(BootstrapMixin, forms.Form):
+    model = Site
+    q = forms.CharField(required=False, label='Search')
+
+
 #
 # Sites
 #
@@ -163,7 +168,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('sites')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -359,17 +364,17 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group_id = FilterChoiceField(
         queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')),
         label='Rack group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('racks')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=RackRole.objects.annotate(filter_count=Count('racks')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -411,7 +416,7 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
     group_id = FilterChoiceField(
         queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
         label='Rack group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 
@@ -1031,7 +1036,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     rack_id = FilterChoiceField(
         queryset=Rack.objects.annotate(filter_count=Count('devices')),
         label='Rack',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
@@ -1040,7 +1045,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
     device_type_id = FilterChoiceField(
@@ -1052,7 +1057,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     platform = FilterChoiceField(
         queryset=Platform.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     mac_address = forms.CharField(required=False, label='MAC address')
@@ -1923,3 +1928,47 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = InventoryItem
         fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
+
+
+class InventoryItemCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Device name or ID',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Manufacturer name',
+        error_messages={
+            'invalid_choice': 'Invalid manufacturer.',
+        }
+    )
+
+    class Meta:
+        model = InventoryItem
+        fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
+
+
+class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput)
+    manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
+    part_id = forms.CharField(max_length=50, required=False, label='Part ID')
+    description = forms.CharField(max_length=100, required=False)
+
+    class Meta:
+        nullable_fields = ['manufacturer', 'part_id', 'description']
+
+
+class InventoryItemFilterForm(BootstrapMixin, forms.Form):
+    model = InventoryItem
+    q = forms.CharField(required=False, label='Search')
+    manufacturer = FilterChoiceField(
+        queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )

+ 15 - 0
netbox/dcim/models.py

@@ -1452,9 +1452,24 @@ class InventoryItem(models.Model):
     discovered = models.BooleanField(default=False, verbose_name='Discovered')
     description = models.CharField(max_length=100, blank=True)
 
+    csv_headers = [
+        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+    ]
+
     class Meta:
         ordering = ['device__id', 'parent__id', 'name']
         unique_together = ['device', 'parent', 'name']
 
     def __str__(self):
         return self.name
+
+    def to_csv(self):
+        return csv_format([
+            self.device.name or '{' + self.device.pk + '}',
+            self.name,
+            self.manufacturer.name if self.manufacturer else None,
+            self.part_id,
+            self.serial,
+            self.asset_tag,
+            self.description
+        ])

+ 29 - 10
netbox/dcim/tables.py

@@ -3,11 +3,12 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
+from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
-    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
+    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
 )
 
 REGION_LINK = """
@@ -140,7 +141,7 @@ class SiteTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
     class Meta(BaseTable.Meta):
         model = Site
@@ -207,7 +208,7 @@ class RackTable(BaseTable):
     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')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     role = tables.TemplateColumn(RACK_ROLE)
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
 
@@ -231,7 +232,7 @@ class RackImportTable(BaseTable):
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     facility_id = tables.Column(verbose_name='Facility ID')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     u_height = tables.Column(verbose_name='Height (U)')
 
     class Meta(BaseTable.Meta):
@@ -381,13 +382,17 @@ class PlatformTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
+    vm_count = tables.Column(verbose_name='VMs')
     slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    actions = tables.TemplateColumn(
+        template_code=PLATFORM_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = Platform
-        fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions')
+        fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
 
 
 #
@@ -398,7 +403,7 @@ class DeviceTable(BaseTable):
     pk = ToggleColumn()
     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')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     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')
@@ -425,7 +430,7 @@ class DeviceDetailTable(DeviceTable):
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
     position = tables.Column(verbose_name='Position')
@@ -523,3 +528,17 @@ class InterfaceConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Interface
         fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
+
+
+#
+# InventoryItems
+#
+
+class InventoryItemTable(BaseTable):
+    pk = ToggleColumn()
+    device = tables.LinkColumn('dcim:device_inventory', args=[Accessor('device.pk')])
+    manufacturer = tables.Column(accessor=Accessor('manufacturer.name'), verbose_name='Manufacturer')
+
+    class Meta(BaseTable.Meta):
+        model = InventoryItem
+        fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')

+ 5 - 1
netbox/dcim/urls.py

@@ -195,9 +195,13 @@ urlpatterns = [
     url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
 
     # Inventory items
-    url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
+    url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
+    url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
+    url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
+    url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
     url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
     url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
+    url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
 
     # Console/power/interface connections
     url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),

+ 46 - 1
netbox/dcim/views.py

@@ -80,6 +80,8 @@ class BulkDisconnectView(View):
 
 class RegionListView(ObjectListView):
     queryset = Region.objects.annotate(site_count=Count('sites'))
+    filter = filters.RegionFilter
+    filter_form = forms.RegionFilterForm
     table = tables.RegionTable
     template_name = 'dcim/region_list.html'
 
@@ -754,7 +756,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PlatformListView(ObjectListView):
-    queryset = Platform.objects.annotate(device_count=Count('devices'))
+    queryset = Platform.objects.annotate(
+        device_count=Count('devices', distinct=True),
+        vm_count=Count('virtual_machines', distinct=True)
+    )
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
 
@@ -1810,6 +1815,14 @@ class InterfaceConnectionsListView(ObjectListView):
 # Inventory items
 #
 
+class InventoryItemListView(ObjectListView):
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    filter = filters.InventoryItemFilter
+    filter_form = forms.InventoryItemFilterForm
+    table = tables.InventoryItemTable
+    template_name = 'dcim/inventoryitem_list.html'
+
+
 class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_inventoryitem'
     model = InventoryItem
@@ -1821,8 +1834,40 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
         return obj
 
+    def get_return_url(self, request, obj):
+        return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
+
 
 class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_inventoryitem'
     model = InventoryItem
     parent_field = 'device'
+
+    def get_return_url(self, request, obj):
+        return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
+
+
+class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_inventoryitem'
+    model_form = forms.InventoryItemCSVForm
+    table = tables.InventoryItemTable
+    default_return_url = 'dcim:inventoryitem_list'
+
+
+class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_inventoryitem'
+    cls = InventoryItem
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    filter = filters.InventoryItemFilter
+    table = tables.InventoryItemTable
+    form = forms.InventoryItemBulkEditForm
+    default_return_url = 'dcim:inventoryitem_list'
+
+
+class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_inventoryitem'
+    cls = InventoryItem
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    table = tables.InventoryItemTable
+    template_name = 'dcim/inventoryitem_bulk_delete.html'
+    default_return_url = 'dcim:inventoryitem_list'

+ 16 - 13
netbox/ipam/forms.py

@@ -78,8 +78,11 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VRF
     q = forms.CharField(required=False, label='Search')
-    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
-                               null_option=(0, None))
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.annotate(filter_count=Count('vrfs')),
+        to_field_name='slug',
+        null_label='-- None --'
+    )
 
 
 #
@@ -368,23 +371,23 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='rd',
         label='VRF',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=Role.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
 
@@ -719,12 +722,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
         to_field_name='rd',
         label='VRF',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
     role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
@@ -766,7 +769,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
         to_field_name='slug',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
 
 
@@ -896,23 +899,23 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
-        null_option=(0, 'Global')
+        null_label='-- Global --'
     )
     group_id = FilterChoiceField(
         queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
         label='VLAN group',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
     role = FilterChoiceField(
         queryset=Role.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 

+ 12 - 4
netbox/ipam/models.py

@@ -283,15 +283,23 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
 
     def get_child_prefixes(self):
         """
-        Return all Prefixes within this Prefix and VRF.
+        Return all Prefixes within this Prefix and VRF. If this Prefix is a container in the global table, return child
+        Prefixes belonging to any VRF.
         """
-        return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
+        if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
+            return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
+        else:
+            return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
 
     def get_child_ips(self):
         """
-        Return all IPAddresses within this Prefix and VRF.
+        Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
+        child IPAddresses belonging to any VRF.
         """
-        return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
+        if self.vrf is None and self.status == PREFIX_STATUS_CONTAINER:
+            return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
+        else:
+            return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
 
     def get_available_prefixes(self):
         """

+ 8 - 13
netbox/ipam/tables.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
+from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
@@ -48,13 +49,7 @@ PREFIX_LINK = """
 {% else %}
     <span class="text-nowrap" style="padding-left: {{ record.depth }}9px">
 {% endif %}
-    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
-</span>
-"""
-
-PREFIX_LINK_BRIEF = """
-<span style="padding-left: {{ record.depth }}0px">
-    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
+    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
 </span>
 """
 
@@ -137,9 +132,9 @@ VLANGROUP_ACTIONS = """
 
 TENANT_LINK = """
 {% if record.tenant %}
-    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}">{{ record.tenant }}</a>
+    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
 {% elif record.vrf.tenant %}
-    <a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}">{{ record.vrf.tenant }}</a>*
+    <a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
 {% else %}
     &mdash;
 {% endif %}
@@ -154,7 +149,7 @@ class VRFTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
     rd = tables.Column(verbose_name='RD')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
     class Meta(BaseTable.Meta):
         model = VRF
@@ -245,7 +240,7 @@ class PrefixTable(BaseTable):
     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)
+    tenant = tables.TemplateColumn(template_code=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)
@@ -274,7 +269,7 @@ class IPAddressTable(BaseTable):
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     status = tables.TemplateColumn(STATUS_LABEL)
-    tenant = tables.TemplateColumn(TENANT_LINK)
+    tenant = tables.TemplateColumn(template_code=TENANT_LINK)
     parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
     interface = tables.Column(orderable=False)
 
@@ -336,7 +331,7 @@ class VLANTable(BaseTable):
     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')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     status = tables.TemplateColumn(STATUS_LABEL)
     role = tables.TemplateColumn(VLAN_ROLE_LINK)
 

+ 3 - 3
netbox/ipam/views.py

@@ -491,11 +491,11 @@ class PrefixPrefixesView(View):
         prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
 
         # Child prefixes table
-        child_prefixes = Prefix.objects.filter(
-            vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
-        ).select_related(
+        child_prefixes = prefix.get_child_prefixes().select_related(
             'site', 'vlan', 'role',
         ).annotate_depth(limit=0)
+
+        # Annotate available prefixes
         if child_prefixes:
             child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
 

+ 1 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.2.8'
+VERSION = '2.2.9'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 

+ 2 - 1
netbox/netbox/views.py

@@ -15,7 +15,7 @@ from circuits.tables import CircuitTable, ProviderTable
 from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
 from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
 from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
-from extras.models import TopologyMap, UserAction
+from extras.models import ReportResult, TopologyMap, UserAction
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
@@ -177,6 +177,7 @@ class HomeView(View):
             'search_form': SearchForm(),
             'stats': stats,
             'topology_maps': TopologyMap.objects.filter(site__isnull=True),
+            'report_results': ReportResult.objects.order_by('-created')[:10],
             'recent_activity': UserAction.objects.select_related('user')[:50]
         })
 

+ 7 - 6
netbox/templates/dcim/device_inventory.html

@@ -64,13 +64,14 @@
                     {% endfor %}
                 </tbody>
             </table>
+            {% if perms.dcim.add_inventoryitem %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
+                        <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
+                    </a>
+                </div>
+            {% endif %}
         </div>
-        {% if perms.dcim.add_inventoryitem %}
-            <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-success">
-                <span class="fa fa-plus" aria-hidden="true"></span>
-                Add Inventory Item
-            </a>
-        {% endif %}
     </div>
 </div>
 {% endblock %}

+ 4 - 1
netbox/templates/dcim/device_lldp_neighbors.html

@@ -58,9 +58,10 @@ $(document).ready(function() {
                 // Glean configured hostnames/interfaces from the DOM
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
+                var configured_interface_short = null;
                 if (configured_interface) {
                     // Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1).
-                    configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
+                    configured_interface_short = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2");
                 }
 
                 // Clean up hostnames/interfaces learned via LLDP
@@ -76,6 +77,8 @@ $(document).ready(function() {
                     row.addClass('info');
                 } else if (configured_device == lldp_device && configured_interface == lldp_interface) {
                     row.addClass('success');
+                } else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
+                    row.addClass('success');
                 } else {
                     row.addClass('danger');
                 }

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

@@ -114,7 +114,7 @@
                     </a>
                 {% endif %}
             {% endif %}
-            <a href="{% if iface.device %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}" class="btn btn-info btn-xs" title="Edit interface">
+            <a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}
@@ -124,7 +124,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
             {% else %}
-                <a href="{% if iface.device %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
+                <a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </a>
             {% endif %}

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

@@ -11,7 +11,7 @@
             <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>
         {% endif %}
         {% if perms.dcim.delete_inventoryitem %}
-            <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
+            <a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
         {% endif %}
     </td>
 </tr>

+ 5 - 0
netbox/templates/dcim/inventoryitem_bulk_delete.html

@@ -0,0 +1,5 @@
+{% extends 'utilities/obj_bulk_delete.html' %}
+
+{% block message_extra %}
+    <p class="text-center text-danger"><i class="fa fa-warning"></i> This will also delete all child inventory items of those listed.</p>
+{% endblock %}

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

@@ -0,0 +1,23 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.dcim.add_devicetype %}
+        <a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-info">
+            <span class="fa fa-download" aria-hidden="true"></span>
+            Import inventory items
+        </a>
+    {% endif %}
+    {% include 'inc/export_button.html' with obj_type='inventory items' %}
+</div>
+<h1>{% block title %}Inventory Items{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 8 - 16
netbox/templates/dcim/rack.html

@@ -24,28 +24,20 @@
     </div>
 </div>
 <div class="pull-right">
-    {% if prev_rack %}
-        <a href="{% url 'dcim:rack' pk=prev_rack.pk %}" class="btn btn-primary">
-            <span class="fa fa-chevron-left" aria-hidden="true"></span>
-            Previous Rack
-        </a>
-    {% endif %}
-    {% if next_rack %}
-        <a href="{% url 'dcim:rack' pk=next_rack.pk %}" class="btn btn-primary">
-            <span class="fa fa-chevron-right" aria-hidden="true"></span>
-            Next Rack
-        </a>
-    {% endif %}
+    <a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
+        <span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
+    </a>
+    <a {% if next_rack %}href="{% url 'dcim:rack' pk=next_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
+        <span class="fa fa-chevron-right" aria-hidden="true"></span> Next Rack
+    </a>
     {% if perms.dcim.change_rack %}
 		<a href="{% url 'dcim:rack_edit' pk=rack.pk %}" class="btn btn-warning">
-			<span class="fa fa-pencil" aria-hidden="true"></span>
-			Edit this rack
+			<span class="fa fa-pencil" aria-hidden="true"></span> Edit this rack
 		</a>
     {% endif %}
     {% if perms.dcim.delete_rack %}
 		<a href="{% url 'dcim:rack_delete' pk=rack.pk %}" class="btn btn-danger">
-			<span class="fa fa-trash" aria-hidden="true"></span>
-			Delete this rack
+			<span class="fa fa-trash" aria-hidden="true"></span> Delete this rack
 		</a>
     {% endif %}
 </div>

+ 4 - 1
netbox/templates/dcim/region_list.html

@@ -17,8 +17,11 @@
 </div>
 <h1>{% block title %}Regions{% endblock %}</h1>
 <div class="row">
-	<div class="col-md-12">
+	<div class="col-md-9">
         {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
     </div>
+	<div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+	</div>
 </div>
 {% endblock %}

+ 2 - 2
netbox/templates/extras/inc/report_label.html

@@ -1,6 +1,6 @@
-{% if report.result.failed %}
+{% if result.failed %}
     <label class="label label-danger">Failed</label>
-{% elif report.result %}
+{% elif result %}
     <label class="label label-success">Passed</label>
 {% else %}
     <label class="label label-default">N/A</label>

+ 1 - 1
netbox/templates/extras/report.html

@@ -22,7 +22,7 @@
             </form>
         </div>
     {% endif %}
-    <h1>{{ report.name }}{% include 'extras/inc/report_label.html' %}</h1>
+    <h1>{{ report.name }}{% include 'extras/inc/report_label.html' with result=report.result %}</h1>
     <div class="row">
         <div class="col-md-12">
             {% if report.description %}

+ 1 - 1
netbox/templates/extras/report_list.html

@@ -24,7 +24,7 @@
                                         <a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
                                     </td>
                                     <td>
-                                        {% include 'extras/inc/report_label.html' %}
+                                        {% include 'extras/inc/report_label.html' with result=report.result %}
                                     </td>
                                     <td>{{ report.description|default:"" }}</td>
                                     {% if report.result %}

+ 15 - 0
netbox/templates/home.html

@@ -150,6 +150,21 @@
                 </div>
             {% endif %}
         </div>
+        {% if report_results %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Reports</strong>
+                </div>
+                <table class="table table-hover panel-body">
+                    {% for result in report_results %}
+                        <span>
+                            <td><a href="{% url 'extras:report' name=result.report %}">{{ result.report }}</a></td>
+                            <td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/report_label.html' %}</span></td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Recent Activity</strong>

+ 11 - 1
netbox/templates/inc/nav_menu.html

@@ -104,7 +104,7 @@
                         </li>
                     </ul>
                 </li>
-                <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/' %} active{% endif %}">
+                <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Devices</li>
@@ -156,6 +156,16 @@
                             <a href="{% url 'dcim:manufacturer_list' %}">Manufacturers</a>
                         </li>
                         <li class="divider"></li>
+                        <li class="dropdown-header">Inventory</li>
+                        <li>
+                            {% if perms.dcim.add_inventoryitem %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'dcim:inventoryitem_list' %}">Inventory Items</a>
+                        </li>
+                        <li class="divider"></li>
                         <li class="dropdown-header">Connections</li>
                         <li>
                             {% if perms.dcim.change_consoleport %}

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

@@ -6,7 +6,7 @@
     {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
     <div class="row">
         <div class="col-md-12">
-            {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
+            {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
         </div>
     </div>
 {% endblock %}

+ 2 - 1
netbox/templates/utilities/obj_bulk_delete.html

@@ -9,7 +9,8 @@
             <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.
+                    <p><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.</p>
+                    {% block message_extra %}{% endblock %}
                 </div>
             </div>
         </div>

+ 1 - 1
netbox/tenancy/forms.py

@@ -81,7 +81,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group = FilterChoiceField(
         queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 

+ 8 - 0
netbox/tenancy/tables.py

@@ -11,6 +11,14 @@ TENANTGROUP_ACTIONS = """
 {% endif %}
 """
 
+COL_TENANT = """
+{% if record.tenant %}
+    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
 
 #
 # Tenant groups

+ 1 - 1
netbox/utilities/filters.py

@@ -42,7 +42,7 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
     """
     iterator = forms.models.ModelChoiceIterator
 
-    def __init__(self, null_value=0, null_label='None', *args, **kwargs):
+    def __init__(self, null_value=0, null_label='-- None --', *args, **kwargs):
         self.null_value = null_value
         self.null_label = null_label
         super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs)

+ 17 - 12
netbox/utilities/forms.py

@@ -407,11 +407,25 @@ class SlugField(forms.SlugField):
         self.widget.attrs['slug-source'] = slug_source
 
 
+class FilterChoiceIterator(forms.models.ModelChoiceIterator):
+
+    def __iter__(self):
+        # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string)
+        if self.field.null_label is not None:
+            yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label)
+        queryset = self.queryset.all()
+        # Can't use iterator() when queryset uses prefetch_related()
+        if not queryset._prefetch_related_lookups:
+            queryset = queryset.iterator()
+        for obj in queryset:
+            yield self.choice(obj)
+
+
 class FilterChoiceFieldMixin(object):
-    iterator = forms.models.ModelChoiceIterator
+    iterator = FilterChoiceIterator
 
-    def __init__(self, null_option=None, *args, **kwargs):
-        self.null_option = null_option
+    def __init__(self, null_label=None, *args, **kwargs):
+        self.null_label = null_label
         if 'required' not in kwargs:
             kwargs['required'] = False
         if 'widget' not in kwargs:
@@ -424,15 +438,6 @@ class FilterChoiceFieldMixin(object):
             return '{} ({})'.format(label, obj.filter_count)
         return label
 
-    def _get_choices(self):
-        if hasattr(self, '_choices'):
-            return self._choices
-        if self.null_option is not None:
-            return itertools.chain([self.null_option], self.iterator(self))
-        return self.iterator(self)
-
-    choices = property(_get_choices, forms.ChoiceField._set_choices)
-
 
 class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
     pass

+ 1 - 1
netbox/utilities/templatetags/helpers.py

@@ -111,7 +111,7 @@ def example_choices(field, arg=3):
         if len(examples) == arg:
             examples.append('etc.')
             break
-        if not id:
+        if not id or not label:
             continue
         examples.append(label)
     return ', '.join(examples) or 'None'

+ 8 - 8
netbox/virtualization/forms.py

@@ -137,13 +137,13 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
     group = FilterChoiceField(
         queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
         required=False,
     )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters')),
         to_field_name='slug',
-        null_option=(0, 'None'),
+        null_label='-- None --',
         required=False,
     )
 
@@ -338,12 +338,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     cluster_group = FilterChoiceField(
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     cluster_type = FilterChoiceField(
         queryset=ClusterType.objects.all(),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     cluster_id = FilterChoiceField(
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
@@ -352,23 +352,23 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
     platform = FilterChoiceField(
         queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
-        null_option=(0, 'None')
+        null_label='-- None --'
     )
 
 

+ 3 - 2
netbox/virtualization/tables.py

@@ -4,6 +4,7 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from dcim.models import Interface
+from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
@@ -24,7 +25,7 @@ VIRTUALMACHINE_STATUS = """
 """
 
 VIRTUALMACHINE_ROLE = """
-<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
+{% if record.role %}<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
 """
 
 VIRTUALMACHINE_PRIMARY_IP = """
@@ -97,7 +98,7 @@ class VirtualMachineTable(BaseTable):
     status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
     cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
     role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
     class Meta(BaseTable.Meta):
         model = VirtualMachine