Browse Source

Merge branch 'develop' into develop-2.3

Jeremy Stretch 7 years ago
parent
commit
6b101d2c49

+ 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')
 

+ 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')
@@ -623,6 +636,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)',
@@ -641,7 +658,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 VirtualChassisFilter(django_filters.FilterSet):

+ 49 - 0
netbox/dcim/forms.py

@@ -92,6 +92,11 @@ class RegionCSVForm(forms.ModelForm):
         }
 
 
+class RegionFilterForm(BootstrapMixin, forms.Form):
+    model = Site
+    q = forms.CharField(required=False, label='Search')
+
+
 #
 # Sites
 #
@@ -2212,6 +2217,50 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
         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 --'
+    )
+
+
 #
 # Virtual chassis
 #

+ 15 - 0
netbox/dcim/models.py

@@ -1558,6 +1558,10 @@ 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']
@@ -1568,6 +1572,17 @@ class InventoryItem(models.Model):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
+    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
+        ])
+
 
 #
 # Virtual chassis

+ 23 - 7
netbox/dcim/tables.py

@@ -3,11 +3,13 @@ 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, VirtualChassis
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
+    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
+    VirtualChassis,
 )
 
 REGION_LINK = """
@@ -147,7 +149,7 @@ class SiteTable(BaseTable):
     name = tables.LinkColumn()
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     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
@@ -199,7 +201,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')
 
@@ -223,7 +225,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):
@@ -396,7 +398,7 @@ class DeviceTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(template_code=DEVICE_LINK)
     status = tables.TemplateColumn(template_code=STATUS_LABEL, 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')
@@ -423,7 +425,7 @@ class DeviceDetailTable(DeviceTable):
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     status = tables.TemplateColumn(template_code=STATUS_LABEL, 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')
@@ -524,6 +526,20 @@ class InterfaceConnectionTable(BaseTable):
 
 
 #
+# 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')
+
+
+#
 # Virtual chassis
 #
 

+ 5 - 1
netbox/dcim/urls.py

@@ -199,9 +199,13 @@ urlpatterns = [
     url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
 
     # 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'),

+ 43 - 0
netbox/dcim/views.py

@@ -125,6 +125,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'
 
@@ -2010,6 +2012,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, ObjectEditView):
     permission_required = 'dcim.change_inventoryitem'
     model = InventoryItem
@@ -2020,6 +2030,9 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
             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, ObjectDeleteView):
     permission_required = 'dcim.delete_inventoryitem'
@@ -2169,3 +2182,33 @@ class VCMembershipEditView(PermissionRequiredMixin, ObjectEditView):
 class VCMembershipDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_vcmembership'
     model = VCMembership
+    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'

+ 12 - 13
netbox/ipam/models.py

@@ -283,24 +283,23 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
 
     def get_child_prefixes(self):
         """
-        Return all child Prefixes within this Prefix.
+        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)
-
-    def get_available_prefixes(self):
-        """
-        Return all available prefixes within this Prefix.
-        """
-        prefix = netaddr.IPSet(self.prefix)
-        child_prefixes = netaddr.IPSet([p.prefix for p in self.get_child_prefixes()])
-        available_prefixes = prefix - child_prefixes
-        return available_prefixes
+        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):
         """

+ 7 - 6
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
 
@@ -131,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 %}
@@ -148,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
@@ -239,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)
@@ -268,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)
 
@@ -330,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)
 

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

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

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

+ 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>

+ 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/templatetags/helpers.py

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

+ 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