diff --git a/docs/api/examples.md b/docs/api/examples.md index 0291f6012..76471c4eb 100644 --- a/docs/api/examples.md +++ b/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 diff --git a/docs/api/overview.md b/docs/api/overview.md index db9c1de4d..ba7e11bbf 100644 --- a/docs/api/overview.md +++ b/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. diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index bd6bcc81f..5aeec0eb1 100644 --- a/docs/installation/ldap.md +++ b/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 diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 530456a56..10dff7f3a 100644 --- a/docs/installation/netbox.md +++ b/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.) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 26d38d56a..8acad4bb9 100644 --- a/netbox/circuits/forms.py +++ b/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')), diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 10f776ea3..9a5225d56 100644 --- a/netbox/circuits/tables.py +++ b/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') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8f6b3ada8..5204e6a0e 100644 --- a/netbox/dcim/api/serializers.py +++ b/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 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e56a12ac0..f3d70edd4 100644 --- a/netbox/dcim/filters.py +++ b/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): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1f5d50c4d..98c075cd3 100644 --- a/netbox/dcim/forms.py +++ b/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 --' + ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d50b35487..2b1f403e7 100644 --- a/netbox/dcim/models.py +++ b/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 + ]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e95277704..0349396fa 100644 --- a/netbox/dcim/tables.py +++ b/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') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..bd10ad216 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -195,9 +195,13 @@ urlpatterns = [ url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), # Inventory items - url(r'^devices/(?P\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\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + url(r'^devices/(?P\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'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b681e4a7..a66cee593 100644 --- a/netbox/dcim/views.py +++ b/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' diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index a5b0a7e3c..c67921e3e 100644 --- a/netbox/ipam/forms.py +++ b/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 --' ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bdaec4fd8..9b30586f2 100644 --- a/netbox/ipam/models.py +++ b/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): """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ebb86731c..32f04c223 100644 --- a/netbox/ipam/tables.py +++ b/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 %} {% endif %} - {{ record.prefix }} - -""" - -PREFIX_LINK_BRIEF = """ - - {{ record.prefix }} + {{ record.prefix }} """ @@ -137,9 +132,9 @@ VLANGROUP_ACTIONS = """ TENANT_LINK = """ {% if record.tenant %} - {{ record.tenant }} + {{ record.tenant }} {% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* + {{ record.vrf.tenant }}* {% else %} — {% 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) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 25475eec8..18e7ff7e5 100644 --- a/netbox/ipam/views.py +++ b/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) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e3c3025f9..35f8264b8 100644 --- a/netbox/netbox/settings.py +++ b/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__))) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 1c21e58b7..e5fb311db 100644 --- a/netbox/netbox/views.py +++ b/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] }) diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 32b15670c..1db2dcefa 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -64,13 +64,14 @@ {% endfor %} + {% if perms.dcim.add_inventoryitem %} + + {% endif %} - {% if perms.dcim.add_inventoryitem %} - - - Add Inventory Item - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index c79cf8955..4fe914f64 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/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'); } diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b0c35a0e9..99fb76a7d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -114,7 +114,7 @@ {% endif %} {% endif %} - + {% endif %} @@ -124,7 +124,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 21de1014e..b50765271 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -11,7 +11,7 @@ {% endif %} {% if perms.dcim.delete_inventoryitem %} - + {% endif %} diff --git a/netbox/templates/dcim/inventoryitem_bulk_delete.html b/netbox/templates/dcim/inventoryitem_bulk_delete.html new file mode 100644 index 000000000..7d3739013 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_bulk_delete.html @@ -0,0 +1,5 @@ +{% extends 'utilities/obj_bulk_delete.html' %} + +{% block message_extra %} +

This will also delete all child inventory items of those listed.

+{% endblock %} diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html new file mode 100644 index 000000000..612534d98 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_list.html @@ -0,0 +1,23 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
+ {% if perms.dcim.add_devicetype %} + + + Import inventory items + + {% endif %} + {% include 'inc/export_button.html' with obj_type='inventory items' %} +
+

{% block title %}Inventory Items{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 05585348f..b5eb8874e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -24,28 +24,20 @@
- {% if prev_rack %} - - - Previous Rack - - {% endif %} - {% if next_rack %} - - - Next Rack - - {% endif %} + + Previous Rack + + + Next Rack + {% if perms.dcim.change_rack %} - - Edit this rack + Edit this rack {% endif %} {% if perms.dcim.delete_rack %} - - Delete this rack + Delete this rack {% endif %}
diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index 73fddbb0a..4d61b4acb 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -17,8 +17,11 @@

{% block title %}Regions{% endblock %}

-
+
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
+
+ {% include 'inc/search_panel.html' %} +
{% endblock %} diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html index 67fc0556b..d4d2c5919 100644 --- a/netbox/templates/extras/inc/report_label.html +++ b/netbox/templates/extras/inc/report_label.html @@ -1,6 +1,6 @@ -{% if report.result.failed %} +{% if result.failed %} -{% elif report.result %} +{% elif result %} {% else %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 2c087e437..050d6e445 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -22,7 +22,7 @@
{% endif %} -

{{ report.name }}{% include 'extras/inc/report_label.html' %}

+

{{ report.name }}{% include 'extras/inc/report_label.html' with result=report.result %}

{% if report.description %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 9cba1a71b..1d6fbb72e 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -24,7 +24,7 @@ {{ report.name }} - {% include 'extras/inc/report_label.html' %} + {% include 'extras/inc/report_label.html' with result=report.result %} {{ report.description|default:"" }} {% if report.result %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index beb157f1a..46bfdbbd5 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -150,6 +150,21 @@
{% endif %}
+ {% if report_results %} +
+
+ Reports +
+ + {% for result in report_results %} + + + + + {% endfor %} +
{{ result.report }}{% include 'extras/inc/report_label.html' %}
+
+ {% endif %}
Recent Activity diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index dd811fb54..1857afcc2 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -104,7 +104,7 @@ -
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5eb3bda61..00194d4e8 100644 --- a/netbox/tenancy/forms.py +++ b/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 --' ) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2b2989941..b3c67e9e2 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -11,6 +11,14 @@ TENANTGROUP_ACTIONS = """ {% endif %} """ +COL_TENANT = """ +{% if record.tenant %} + {{ record.tenant }} +{% else %} + — +{% endif %} +""" + # # Tenant groups diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 647ecb723..3e403e676 100644 --- a/netbox/utilities/filters.py +++ b/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) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 6ba49d02c..1817cd9a9 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -407,11 +407,25 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceFieldMixin(object): - iterator = forms.models.ModelChoiceIterator +class FilterChoiceIterator(forms.models.ModelChoiceIterator): - def __init__(self, null_option=None, *args, **kwargs): - self.null_option = null_option + 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 = FilterChoiceIterator + + 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 diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 2af936885..4ed1aeced 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/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' diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 16b33962c..d697de755 100644 --- a/netbox/virtualization/forms.py +++ b/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 --' ) diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 0498edd46..2ace86d77 100644 --- a/netbox/virtualization/tables.py +++ b/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 = """ - +{% if record.role %}{% else %}—{% 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