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

Merge pull request #4555 from netbox-community/492-table-column-ordering

Closes #492: Table column ordering
Jeremy Stretch 5 лет назад
Родитель
Сommit
80f08e6830

+ 1 - 0
docs/development/user-preferences.md

@@ -8,3 +8,4 @@ The `users.UserConfig` model holds individual preferences for each user in the f
 | ---- | ----------- |
 | extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) |
 | pagination.per_page | The number of items to display per page of a paginated table |
+| tables.${table_name}.columns | The ordered list of columns to display when viewing the table |

+ 12 - 10
netbox/circuits/tables.py

@@ -27,18 +27,15 @@ STATUS_LABEL = """
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
+    circuit_count = tables.Column(
+        accessor=Accessor('count_circuits'),
+        verbose_name='Circuits'
+    )
 
     class Meta(BaseTable.Meta):
         model = Provider
-        fields = ('pk', 'name', 'asn', 'account',)
-
-
-class ProviderDetailTable(ProviderTable):
-    circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
-
-    class Meta(ProviderTable.Meta):
-        model = Provider
-        fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
+        fields = ('pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count')
+        default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
 #
@@ -58,6 +55,7 @@ class CircuitTypeTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = CircuitType
         fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
 
 
 #
@@ -79,4 +77,8 @@ class CircuitTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Circuit
-        fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
+        fields = (
+            'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate',
+            'description',
+        )
+        default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description')

+ 3 - 3
netbox/circuits/views.py

@@ -28,7 +28,7 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView):
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filterset = filters.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
-    table = tables.ProviderDetailTable
+    table = tables.ProviderTable
 
 
 class ProviderView(PermissionRequiredMixin, View):
@@ -87,7 +87,7 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
 
 class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'circuits.change_provider'
-    queryset = Provider.objects.all()
+    queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
     form = forms.ProviderBulkEditForm
@@ -96,7 +96,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
 
 class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_provider'
-    queryset = Provider.objects.all()
+    queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
     default_return_url = 'circuits:provider_list'

+ 252 - 80
netbox/dcim/tables.py

@@ -205,9 +205,13 @@ def get_component_template_actions(model_name):
 
 class RegionTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.TemplateColumn(template_code=MPTT_LINK, orderable=False)
-    site_count = tables.Column(verbose_name='Sites')
-    slug = tables.Column(verbose_name='Slug')
+    name = tables.TemplateColumn(
+        template_code=MPTT_LINK,
+        orderable=False
+    )
+    site_count = tables.Column(
+        verbose_name='Sites'
+    )
     actions = tables.TemplateColumn(
         template_code=REGION_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -216,7 +220,8 @@ class RegionTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Region
-        fields = ('pk', 'name', 'site_count', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 #
@@ -225,14 +230,27 @@ class RegionTable(BaseTable):
 
 class SiteTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(order_by=('_name',))
-    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
-    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    name = tables.LinkColumn(
+        order_by=('_name',)
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    region = tables.TemplateColumn(
+        template_code=SITE_REGION_LINK
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
 
     class Meta(BaseTable.Meta):
         model = Site
-        fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
+        fields = (
+            'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
+            'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'contact_email',
+        )
+        default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
 
 
 #
@@ -253,7 +271,6 @@ class RackGroupTable(BaseTable):
     rack_count = tables.Column(
         verbose_name='Racks'
     )
-    slug = tables.Column()
     actions = tables.TemplateColumn(
         template_code=RACKGROUP_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -263,6 +280,7 @@ class RackGroupTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = RackGroup
         fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
 
 
 #
@@ -282,6 +300,7 @@ class RackRoleTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = RackRole
         fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
 
 
 #
@@ -290,17 +309,34 @@ class RackRoleTable(BaseTable):
 
 class RackTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(order_by=('_name',))
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
-    status = tables.TemplateColumn(STATUS_LABEL)
-    role = tables.TemplateColumn(RACK_ROLE)
-    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
+    name = tables.LinkColumn(
+        order_by=('_name',)
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    role = tables.TemplateColumn(
+        template_code=RACK_ROLE
+    )
+    u_height = tables.TemplateColumn(
+        template_code="{{ record.u_height }}U",
+        verbose_name='Height'
+    )
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
+        fields = (
+            'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+            'width', 'u_height',
+        )
+        default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
 
 
 class RackDetailTable(RackTable):
@@ -321,6 +357,10 @@ class RackDetailTable(RackTable):
 
     class Meta(RackTable.Meta):
         fields = (
+            'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+            'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization',
+        )
+        default_columns = (
             'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
             'get_utilization', 'get_power_utilization',
         )
@@ -364,6 +404,9 @@ class RackReservationTable(BaseTable):
         fields = (
             'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions',
         )
+        default_columns = (
+            'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
+        )
 
 
 #
@@ -416,9 +459,12 @@ class DeviceTypeTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = DeviceType
         fields = (
-            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
             'instance_count',
         )
+        default_columns = (
+            'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
+        )
 
 
 #
@@ -427,7 +473,9 @@ class DeviceTypeTable(BaseTable):
 
 class ConsolePortTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -441,7 +489,10 @@ class ConsolePortTemplateTable(BaseTable):
 
 
 class ConsolePortImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = ConsolePort
@@ -451,7 +502,9 @@ class ConsolePortImportTable(BaseTable):
 
 class ConsoleServerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleserverporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -465,7 +518,10 @@ class ConsoleServerPortTemplateTable(BaseTable):
 
 
 class ConsoleServerPortImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = ConsoleServerPort
@@ -475,7 +531,9 @@ class ConsoleServerPortImportTable(BaseTable):
 
 class PowerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('powerporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -489,7 +547,10 @@ class PowerPortTemplateTable(BaseTable):
 
 
 class PowerPortImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = PowerPort
@@ -499,7 +560,9 @@ class PowerPortImportTable(BaseTable):
 
 class PowerOutletTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('poweroutlettemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -513,7 +576,10 @@ class PowerOutletTemplateTable(BaseTable):
 
 
 class PowerOutletImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = PowerOutlet
@@ -523,7 +589,9 @@ class PowerOutletImportTable(BaseTable):
 
 class InterfaceTemplateTable(BaseTable):
     pk = ToggleColumn()
-    mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}")
+    mgmt_only = tables.TemplateColumn(
+        template_code="{% if value %}OOB Management{% endif %}"
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('interfacetemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -537,18 +605,30 @@ class InterfaceTemplateTable(BaseTable):
 
 
 class InterfaceImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
-    virtual_machine = tables.LinkColumn('virtualization:virtualmachine', args=[Accessor('virtual_machine.pk')], verbose_name='Virtual Machine')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
+    virtual_machine = tables.LinkColumn(
+        viewname='virtualization:virtualmachine',
+        args=[Accessor('virtual_machine.pk')],
+        verbose_name='Virtual Machine'
+    )
 
     class Meta(BaseTable.Meta):
         model = Interface
-        fields = ('device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode')
+        fields = (
+            'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu',
+            'mgmt_only', 'mode',
+        )
         empty_text = False
 
 
 class FrontPortTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     rear_port_position = tables.Column(
         verbose_name='Position'
     )
@@ -565,7 +645,10 @@ class FrontPortTemplateTable(BaseTable):
 
 
 class FrontPortImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = FrontPort
@@ -575,7 +658,9 @@ class FrontPortImportTable(BaseTable):
 
 class RearPortTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('rearporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -589,7 +674,10 @@ class RearPortTemplateTable(BaseTable):
 
 
 class RearPortImportTable(BaseTable):
-    device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
+    device = tables.LinkColumn(
+        viewname='dcim:device',
+        args=[Accessor('device.pk')]
+    )
 
     class Meta(BaseTable.Meta):
         model = RearPort
@@ -599,7 +687,9 @@ class RearPortImportTable(BaseTable):
 
 class DeviceBayTemplateTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(order_by=('_name',))
+    name = tables.Column(
+        order_by=('_name',)
+    )
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('devicebaytemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -630,8 +720,10 @@ class DeviceRoleTable(BaseTable):
         orderable=False,
         verbose_name='VMs'
     )
-    color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
-    slug = tables.Column(verbose_name='Slug')
+    color = tables.TemplateColumn(
+        template_code=COLOR_LABEL,
+        verbose_name='Label'
+    )
     actions = tables.TemplateColumn(
         template_code=DEVICEROLE_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -641,6 +733,7 @@ class DeviceRoleTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
 
 
 #
@@ -670,7 +763,11 @@ class PlatformTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Platform
         fields = (
-            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'description', 'actions',
+            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
+            'description', 'actions',
+        )
+        default_columns = (
+            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
         )
 
 
@@ -684,40 +781,96 @@ class DeviceTable(BaseTable):
         order_by=('_name',),
         template_code=DEVICE_LINK
     )
-    status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
-    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')
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    rack = tables.LinkColumn(
+        viewname='dcim:rack',
+        args=[Accessor('rack.pk')]
+    )
+    device_role = tables.TemplateColumn(
+        template_code=DEVICE_ROLE,
+        verbose_name='Role'
+    )
     device_type = tables.LinkColumn(
-        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
+        viewname='dcim:devicetype',
+        args=[Accessor('device_type.pk')],
+        verbose_name='Type',
         text=lambda record: record.device_type.display_name
     )
-
-    class Meta(BaseTable.Meta):
-        model = Device
-        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
-
-
-class DeviceDetailTable(DeviceTable):
     primary_ip = tables.TemplateColumn(
-        orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
+        template_code=DEVICE_PRIMARY_IP,
+        orderable=False,
+        verbose_name='IP Address'
+    )
+    primary_ip4 = tables.LinkColumn(
+        viewname='ipam:ipaddress',
+        args=[Accessor('primary_ip4.pk')],
+        verbose_name='IPv4 Address'
+    )
+    primary_ip6 = tables.LinkColumn(
+        viewname='ipam:ipaddress',
+        args=[Accessor('primary_ip6.pk')],
+        verbose_name='IPv6 Address'
+    )
+    cluster = tables.LinkColumn(
+        viewname='virtualization:cluster',
+        args=[Accessor('cluster.pk')]
+    )
+    virtual_chassis = tables.LinkColumn(
+        viewname='dcim:virtualchassis',
+        args=[Accessor('virtual_chassis.pk')]
+    )
+    vc_position = tables.Column(
+        verbose_name='VC Position'
+    )
+    vc_priority = tables.Column(
+        verbose_name='VC Priority'
     )
 
-    class Meta(DeviceTable.Meta):
+    class Meta(BaseTable.Meta):
         model = Device
-        fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
+        fields = (
+            'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
+            'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
+            'vc_position', 'vc_priority',
+        )
+        default_columns = (
+            'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
+        )
 
 
 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.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')
-    device_role = tables.Column(verbose_name='Role')
-    device_type = tables.Column(verbose_name='Type')
+    name = tables.TemplateColumn(
+        template_code=DEVICE_LINK
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    rack = tables.LinkColumn(
+        viewname='dcim:rack',
+        args=[Accessor('rack.pk')]
+    )
+    device_role = tables.Column(
+        verbose_name='Role'
+    )
+    device_type = tables.Column(
+        verbose_name='Type'
+    )
 
     class Meta(BaseTable.Meta):
         model = Device
@@ -893,23 +1046,23 @@ class CableTable(BaseTable):
         template_code=CABLE_TERMINATION_PARENT,
         accessor=Accessor('termination_a'),
         orderable=False,
-        verbose_name='Termination A'
+        verbose_name='Side A'
     )
     termination_a = tables.LinkColumn(
         accessor=Accessor('termination_a'),
         orderable=False,
-        verbose_name=''
+        verbose_name='Termination A'
     )
     termination_b_parent = tables.TemplateColumn(
         template_code=CABLE_TERMINATION_PARENT,
         accessor=Accessor('termination_b'),
         orderable=False,
-        verbose_name='Termination B'
+        verbose_name='Side B'
     )
     termination_b = tables.LinkColumn(
         accessor=Accessor('termination_b'),
         orderable=False,
-        verbose_name=''
+        verbose_name='Termination B'
     )
     status = tables.TemplateColumn(
         template_code=STATUS_LABEL
@@ -926,6 +1079,10 @@ class CableTable(BaseTable):
             'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
             'status', 'type', 'color', 'length',
         )
+        default_columns = (
+            'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
+            'status', 'type',
+        )
 
 
 #
@@ -993,10 +1150,6 @@ class InterfaceConnectionTable(BaseTable):
         args=[Accessor('pk')],
         verbose_name='Interface A'
     )
-    description_a = tables.Column(
-        accessor=Accessor('description'),
-        verbose_name='Description'
-    )
     device_b = tables.LinkColumn(
         viewname='dcim:device',
         accessor=Accessor('_connected_interface.device'),
@@ -1009,15 +1162,11 @@ class InterfaceConnectionTable(BaseTable):
         args=[Accessor('_connected_interface.pk')],
         verbose_name='Interface B'
     )
-    description_b = tables.Column(
-        accessor=Accessor('_connected_interface.description'),
-        verbose_name='Description'
-    )
 
     class Meta(BaseTable.Meta):
         model = Interface
         fields = (
-            'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status',
+            'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status',
         )
 
 
@@ -1027,12 +1176,21 @@ class InterfaceConnectionTable(BaseTable):
 
 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')
+    device = tables.LinkColumn(
+        viewname='dcim:device_inventory',
+        args=[Accessor('device.pk')]
+    )
+    manufacturer = tables.Column(
+        accessor=Accessor('manufacturer.name')
+    )
+    discovered = BooleanColumn()
 
     class Meta(BaseTable.Meta):
         model = InventoryItem
-        fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
+        fields = (
+            'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered'
+        )
+        default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 
 
 #
@@ -1052,6 +1210,7 @@ class VirtualChassisTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = VirtualChassis
         fields = ('pk', 'name', 'domain', 'member_count')
+        default_columns = ('pk', 'name', 'domain', 'member_count')
 
 
 #
@@ -1073,6 +1232,7 @@ class PowerPanelTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = PowerPanel
         fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
+        default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
 
 
 #
@@ -1096,7 +1256,19 @@ class PowerFeedTable(BaseTable):
     type = tables.TemplateColumn(
         template_code=TYPE_LABEL
     )
+    max_utilization = tables.TemplateColumn(
+        template_code="{{ value }}%"
+    )
+    available_power = tables.Column(
+        verbose_name='Available power (VA)'
+    )
 
     class Meta(BaseTable.Meta):
         model = PowerFeed
-        fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase')
+        fields = (
+            'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
+            'max_utilization', 'available_power',
+        )
+        default_columns = (
+            'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
+        )

+ 2 - 6
netbox/dcim/views.py

@@ -1095,7 +1095,7 @@ class DeviceListView(PermissionRequiredMixin, ObjectListView):
     )
     filterset = filters.DeviceFilterSet
     filterset_form = forms.DeviceFilterForm
-    table = tables.DeviceDetailTable
+    table = tables.DeviceTable
     template_name = 'dcim/device_list.html'
 
 
@@ -2278,19 +2278,15 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
         csv_data = [
             # Headers
             ','.join([
-                'device_a', 'interface_a', 'interface_a_description',
-                'device_b', 'interface_b', 'interface_b_description',
-                'connection_status'
+                'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'
             ])
         ]
         for obj in self.queryset:
             csv = csv_format([
                 obj.connected_endpoint.device.identifier if obj.connected_endpoint else None,
                 obj.connected_endpoint.name if obj.connected_endpoint else None,
-                obj.connected_endpoint.description if obj.connected_endpoint else None,
                 obj.device.identifier,
                 obj.name,
-                obj.description,
                 obj.get_connection_status_display(),
             ])
             csv_data.append(csv)

+ 5 - 1
netbox/extras/tables.py

@@ -104,7 +104,11 @@ class ConfigContextTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ConfigContext
-        fields = ('pk', 'name', 'weight', 'is_active', 'description')
+        fields = (
+            'pk', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
+        )
+        default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
 
 
 class ObjectChangeTable(BaseTable):

+ 220 - 63
netbox/ipam/tables.py

@@ -190,12 +190,20 @@ TENANT_LINK = """
 class VRFTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    rd = tables.Column(verbose_name='RD')
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    rd = tables.Column(
+        verbose_name='RD'
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    enforce_unique = BooleanColumn(
+        verbose_name='Unique'
+    )
 
     class Meta(BaseTable.Meta):
         model = VRF
-        fields = ('pk', 'name', 'rd', 'tenant', 'description')
+        fields = ('pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description')
+        default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
 #
@@ -204,14 +212,23 @@ class VRFTable(BaseTable):
 
 class RIRTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    is_private = BooleanColumn(verbose_name='Private')
-    aggregate_count = tables.Column(verbose_name='Aggregates')
-    actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='')
+    name = tables.LinkColumn()
+    is_private = BooleanColumn(
+        verbose_name='Private'
+    )
+    aggregate_count = tables.Column(
+        verbose_name='Aggregates'
+    )
+    actions = tables.TemplateColumn(
+        template_code=RIR_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = RIR
-        fields = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
 
 
 class RIRDetailTable(RIRTable):
@@ -247,6 +264,10 @@ class RIRDetailTable(RIRTable):
 
     class Meta(RIRTable.Meta):
         fields = (
+            'pk', 'name', 'slug', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
+            'stats_deprecated', 'stats_available', 'utilization', 'actions',
+        )
+        default_columns = (
             'pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
             'stats_deprecated', 'stats_available', 'utilization', 'actions',
         )
@@ -258,8 +279,13 @@ class RIRDetailTable(RIRTable):
 
 class AggregateTable(BaseTable):
     pk = ToggleColumn()
-    prefix = tables.LinkColumn(verbose_name='Aggregate')
-    date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
+    prefix = tables.LinkColumn(
+        verbose_name='Aggregate'
+    )
+    date_added = tables.DateColumn(
+        format="Y-m-d",
+        verbose_name='Added'
+    )
 
     class Meta(BaseTable.Meta):
         model = Aggregate
@@ -267,8 +293,13 @@ class AggregateTable(BaseTable):
 
 
 class AggregateDetailTable(AggregateTable):
-    child_count = tables.Column(verbose_name='Prefixes')
-    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
+    child_count = tables.Column(
+        verbose_name='Prefixes'
+    )
+    utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False
+    )
 
     class Meta(AggregateTable.Meta):
         fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
@@ -300,7 +331,8 @@ class RoleTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Role
-        fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'slug', 'weight', 'actions')
+        fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
+        default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
 
 
 #
@@ -309,28 +341,61 @@ class RoleTable(BaseTable):
 
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
-    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(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)
+    prefix = tables.TemplateColumn(
+        template_code=PREFIX_LINK,
+        attrs={'th': {'style': 'padding-left: 17px'}}
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    vrf = tables.TemplateColumn(
+        template_code=VRF_LINK,
+        verbose_name='VRF'
+    )
+    tenant = tables.TemplateColumn(
+        template_code=TENANT_LINK
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    vlan = tables.LinkColumn(
+        viewname='ipam:vlan',
+        args=[Accessor('vlan.pk')],
+        verbose_name='VLAN'
+    )
+    role = tables.TemplateColumn(
+        template_code=PREFIX_ROLE_LINK
+    )
+    is_pool = BooleanColumn(
+        verbose_name='Pool'
+    )
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
+        default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
         row_attrs = {
             'class': lambda record: 'success' if not record.pk else '',
         }
 
 
 class PrefixDetailTable(PrefixTable):
-    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
 
     class Meta(PrefixTable.Meta):
-        fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
+        fields = (
+            'pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description',
+        )
+        default_columns = (
+            'pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
+        )
 
 
 #
@@ -339,12 +404,27 @@ class PrefixDetailTable(PrefixTable):
 
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
-    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(template_code=TENANT_LINK)
-    parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
-    interface = tables.Column(orderable=False)
+    address = tables.TemplateColumn(
+        template_code=IPADDRESS_LINK,
+        verbose_name='IP Address'
+    )
+    vrf = tables.TemplateColumn(
+        template_code=VRF_LINK,
+        verbose_name='VRF'
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=TENANT_LINK
+    )
+    parent = tables.TemplateColumn(
+        template_code=IPADDRESS_PARENT,
+        orderable=False
+    )
+    interface = tables.Column(
+        orderable=False
+    )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
@@ -358,22 +438,40 @@ class IPAddressTable(BaseTable):
 
 class IPAddressDetailTable(IPAddressTable):
     nat_inside = tables.LinkColumn(
-        'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
+        viewname='ipam:ipaddress',
+        args=[Accessor('nat_inside.pk')],
+        orderable=False,
+        verbose_name='NAT (Inside)'
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
     )
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
     class Meta(IPAddressTable.Meta):
         fields = (
             'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
             'description',
         )
+        default_columns = (
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+        )
 
 
 class IPAddressAssignTable(BaseTable):
-    address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address')
-    status = tables.TemplateColumn(STATUS_LABEL)
-    parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
-    interface = tables.Column(orderable=False)
+    address = tables.TemplateColumn(
+        template_code=IPADDRESS_ASSIGN_LINK,
+        verbose_name='IP Address'
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    parent = tables.TemplateColumn(
+        template_code=IPADDRESS_PARENT,
+        orderable=False
+    )
+    interface = tables.Column(
+        orderable=False
+    )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
@@ -385,10 +483,19 @@ class InterfaceIPAddressTable(BaseTable):
     """
     List IP addresses assigned to a specific Interface.
     """
-    address = tables.LinkColumn(verbose_name='IP Address')
-    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    status = tables.TemplateColumn(STATUS_LABEL)
-    tenant = tables.TemplateColumn(template_code=TENANT_LINK)
+    address = tables.LinkColumn(
+        verbose_name='IP Address'
+    )
+    vrf = tables.TemplateColumn(
+        template_code=VRF_LINK,
+        verbose_name='VRF'
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    tenant = tables.TemplateColumn(
+        template_code=TENANT_LINK
+    )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
@@ -401,16 +508,24 @@ class InterfaceIPAddressTable(BaseTable):
 
 class VLANGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    vlan_count = tables.Column(verbose_name='VLANs')
-    slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
-                                    verbose_name='')
+    name = tables.LinkColumn()
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    vlan_count = tables.Column(
+        verbose_name='VLANs'
+    )
+    actions = tables.TemplateColumn(
+        template_code=VLANGROUP_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = VLANGroup
         fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions')
+        default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions')
 
 
 #
@@ -419,12 +534,27 @@ class VLANGroupTable(BaseTable):
 
 class VLANTable(BaseTable):
     pk = ToggleColumn()
-    vid = tables.TemplateColumn(VLAN_LINK, verbose_name='ID')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    group = tables.LinkColumn('ipam:vlangroup_vlans', args=[Accessor('group.pk')], verbose_name='Group')
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
-    status = tables.TemplateColumn(STATUS_LABEL)
-    role = tables.TemplateColumn(VLAN_ROLE_LINK)
+    vid = tables.TemplateColumn(
+        template_code=VLAN_LINK,
+        verbose_name='ID'
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    group = tables.LinkColumn(
+        viewname='ipam:vlangroup_vlans',
+        args=[Accessor('group.pk')]
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    role = tables.TemplateColumn(
+        template_code=VLAN_ROLE_LINK
+    )
 
     class Meta(BaseTable.Meta):
         model = VLAN
@@ -435,16 +565,26 @@ class VLANTable(BaseTable):
 
 
 class VLANDetailTable(VLANTable):
-    prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    prefixes = tables.TemplateColumn(
+        template_code=VLAN_PREFIXES,
+        orderable=False,
+        verbose_name='Prefixes'
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
 
     class Meta(VLANTable.Meta):
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
 
 
 class VLANMemberTable(BaseTable):
-    parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
-    name = tables.LinkColumn(verbose_name='Interface')
+    parent = tables.LinkColumn(
+        order_by=['device', 'virtual_machine']
+    )
+    name = tables.LinkColumn(
+        verbose_name='Interface'
+    )
     untagged = tables.TemplateColumn(
         template_code=VLAN_MEMBER_UNTAGGED,
         orderable=False
@@ -464,13 +604,29 @@ class InterfaceVLANTable(BaseTable):
     """
     List VLANs assigned to a specific Interface.
     """
-    vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
+    vid = tables.LinkColumn(
+        viewname='ipam:vlan',
+        args=[Accessor('pk')],
+        verbose_name='ID'
+    )
     tagged = BooleanColumn()
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
-    status = tables.TemplateColumn(STATUS_LABEL)
-    role = tables.TemplateColumn(VLAN_ROLE_LINK)
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    group = tables.Column(
+        accessor=Accessor('group.name'),
+        verbose_name='Group'
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    role = tables.TemplateColumn(
+        template_code=VLAN_ROLE_LINK
+    )
 
     class Meta(BaseTable.Meta):
         model = VLAN
@@ -494,4 +650,5 @@ class ServiceTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Service
-        fields = ('pk', 'name', 'parent', 'protocol', 'port', 'description')
+        fields = ('pk', 'name', 'parent', 'protocol', 'port', 'ipaddresses', 'description')
+        default_columns = ('pk', 'name', 'parent', 'protocol', 'port', 'description')

+ 3 - 3
netbox/netbox/views.py

@@ -20,7 +20,7 @@ from dcim.models import (
     Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis
 )
 from dcim.tables import (
-    CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
+    CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
     VirtualChassisTable,
 )
 from extras.models import ObjectChange, ReportResult
@@ -44,7 +44,7 @@ SEARCH_TYPES = OrderedDict((
     # Circuits
     ('provider', {
         'permission': 'circuits.view_provider',
-        'queryset': Provider.objects.all(),
+        'queryset': Provider.objects.annotate(count_circuits=Count('circuits')),
         'filterset': ProviderFilterSet,
         'table': ProviderTable,
         'url': 'circuits:provider_list',
@@ -93,7 +93,7 @@ SEARCH_TYPES = OrderedDict((
             'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
         ),
         'filterset': DeviceFilterSet,
-        'table': DeviceDetailTable,
+        'table': DeviceTable,
         'url': 'dcim:device_list',
     }),
     ('virtualchassis', {

+ 29 - 0
netbox/project-static/js/forms.js

@@ -448,4 +448,33 @@ $(document).ready(function() {
     $('a.image-preview').on('mouseout', function() {
         $('#image-preview-window').fadeOut('fast');
     });
+
+    // Rearrange options within a <select> list
+    $('#move-option-up').bind('click', function() {
+        var select_id = '#' + $(this).attr('data-target');
+        $(select_id + ' option:selected').each(function () {
+            var newPos = $(select_id + ' option').index(this) - 1;
+            if (newPos > -1) {
+                $(select_id + ' option').eq(newPos).before("<option value='" + $(this).val() + "' selected='selected'>" + $(this).text() + "</option>");
+                $(this).remove();
+            }
+        });
+    });
+    $('#move-option-down').bind('click', function() {
+        var select_id = '#' + $(this).attr('data-target');
+        var countOptions = $(select_id + ' option').length;
+        var countSelectedOptions = $(select_id + ' option:selected').length;
+        $(select_id + ' option:selected').each(function () {
+            var newPos = $(select_id + ' option').index(this) + countSelectedOptions;
+            if (newPos < countOptions) {
+                $(select_id + ' option').eq(newPos).after("<option value='" + $(this).val() + "' selected='selected'>" + $(this).text() + "</option>");
+                $(this).remove();
+            }
+        });
+    });
+    $('#select-all-options').bind('click', function() {
+        var select_id = '#' + $(this).attr('data-target');
+        $(select_id + ' option').prop('selected',true);
+    });
+
 });

+ 10 - 4
netbox/secrets/tables.py

@@ -20,14 +20,19 @@ SECRETROLE_ACTIONS = """
 class SecretRoleTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    secret_count = tables.Column(verbose_name='Secrets')
+    secret_count = tables.Column(
+        verbose_name='Secrets'
+    )
     actions = tables.TemplateColumn(
-        template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
+        template_code=SECRETROLE_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
     )
 
     class Meta(BaseTable.Meta):
         model = SecretRole
-        fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'actions')
+        fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'users', 'groups', 'actions')
+        default_columns = ('pk', 'name', 'secret_count', 'description', 'actions')
 
 
 #
@@ -40,4 +45,5 @@ class SecretTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Secret
-        fields = ('pk', 'device', 'role', 'name', 'last_updated')
+        fields = ('pk', 'device', 'role', 'name', 'last_updated', 'hash')
+        default_columns = ('pk', 'device', 'role', 'name', 'last_updated')

+ 28 - 0
netbox/templates/inc/table_config_form.html

@@ -0,0 +1,28 @@
+{% load form_helpers %}
+<div class="modal fade" tabindex="-1" id="tableconfig">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+                <h4 class="modal-title">Table Configuration</h4>
+            </div>
+            <div class="modal-body">
+                <form action="" method="post" class="form-horizontal">
+                    {% csrf_token %}
+                    {% render_form table_config_form %}
+                    <div class="row">
+                        <div class="col-md-9 col-md-offset-3">
+                            <a class="btn btn-primary btn-xs" id="move-option-up" data-target="id_columns"><i class="fa fa-arrow-up"></i> Move up</a>
+                            <a class="btn btn-primary btn-xs" id="move-option-down" data-target="id_columns"><i class="fa fa-arrow-down"></i> Move down</a>
+                            <a class="btn btn-success btn-xs" id="select-all-options" data-target="id_columns"><i class="fa fa-ellipsis-v"></i> Select all</a>
+                        </div>
+                    </div>
+                    <div class="text-right">
+                        <input type="submit" class="btn btn-primary" name="set" value="Save" />
+                        <input type="submit" class="btn btn-danger" name="clear" value="Reset" />
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>

+ 6 - 0
netbox/templates/utilities/obj_list.html

@@ -5,6 +5,9 @@
 {% block content %}
 <div class="pull-right noprint">
     {% block buttons %}{% endblock %}
+    {% if table_config_form %}
+        <button type="button" class="btn btn-default" data-toggle="modal" data-target="#tableconfig" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
+    {% endif %}
     {% if permissions.add and 'add' in action_buttons %}
         {% add_button content_type.model_class|url_name:"add" %}
     {% endif %}
@@ -68,6 +71,9 @@
         {% endwith %}
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         <div class="clearfix"></div>
+        {% if table_config_form %}
+            {% include 'inc/table_config_form.html' %}
+        {% endif %}
     </div>
     {% if filter_form %}
         <div class="col-md-3 noprint">

+ 3 - 2
netbox/tenancy/tables.py

@@ -44,7 +44,6 @@ class TenantGroupTable(BaseTable):
     tenant_count = tables.Column(
         verbose_name='Tenants'
     )
-    slug = tables.Column()
     actions = tables.TemplateColumn(
         template_code=TENANTGROUP_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -54,6 +53,7 @@ class TenantGroupTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = TenantGroup
         fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions')
+        default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
 
 
 #
@@ -66,4 +66,5 @@ class TenantTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Tenant
-        fields = ('pk', 'name', 'group', 'description')
+        fields = ('pk', 'name', 'slug', 'group', 'description')
+        default_columns = ('pk', 'name', 'group', 'description')

+ 5 - 3
netbox/users/models.py

@@ -108,7 +108,7 @@ class UserConfig(models.Model):
 
             userconfig.clear('foo.bar.baz')
 
-        A KeyError is raised in the event any key along the path does not exist.
+        Invalid keys will be ignored silently.
 
         :param path: Dotted path to the configuration key. For example, 'foo.bar' deletes self.data['foo']['bar'].
         :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
@@ -117,11 +117,13 @@ class UserConfig(models.Model):
         keys = path.split('.')
 
         for key in keys[:-1]:
-            if key in d and type(d[key]) is dict:
+            if key not in d:
+                break
+            if type(d[key]) is dict:
                 d = d[key]
 
         key = keys[-1]
-        del(d[key])
+        d.pop(key, None)  # Avoid a KeyError on invalid keys
 
         if commit:
             self.save()

+ 0 - 0
netbox/users/tests/__init__.py


+ 2 - 3
netbox/users/tests/test_models.py

@@ -104,6 +104,5 @@ class UserConfigTest(TestCase):
         self.assertTrue('foo' not in userconfig.data['b'])
         self.assertEqual(userconfig.data['b']['bar'], 102)
 
-        # Clear an invalid value
-        with self.assertRaises(KeyError):
-            userconfig.clear('invalid')
+        # Clear a non-existing value; should fail silently
+        userconfig.clear('invalid')

+ 24 - 1
netbox/utilities/forms.py

@@ -665,7 +665,10 @@ class BootstrapMixin(forms.BaseForm):
         super().__init__(*args, **kwargs)
 
         exempt_widgets = [
-            forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect
+            forms.CheckboxInput,
+            forms.ClearableFileInput,
+            forms.FileInput,
+            forms.RadioSelect
         ]
 
         for field_name, field in self.fields.items():
@@ -752,3 +755,23 @@ class ImportForm(BootstrapMixin, forms.Form):
                 raise forms.ValidationError({
                     'data': "Invalid YAML data: {}".format(err)
                 })
+
+
+class TableConfigForm(BootstrapMixin, forms.Form):
+    """
+    Form for configuring user's table preferences.
+    """
+    columns = forms.MultipleChoiceField(
+        choices=[],
+        widget=forms.SelectMultiple(
+            attrs={'size': 10}
+        ),
+        help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display."
+    )
+
+    def __init__(self, table, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Initialize columns field based on table attributes
+        self.fields['columns'].choices = table.configurable_columns
+        self.fields['columns'].initial = table.visible_columns

+ 64 - 5
netbox/utilities/tables.py

@@ -1,4 +1,7 @@
 import django_tables2 as tables
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import ForeignKey
+from django_tables2.data import TableQuerysetData
 from django.utils.safestring import mark_safe
 
 
@@ -6,17 +9,73 @@ class BaseTable(tables.Table):
     """
     Default table for object lists
     """
-    def __init__(self, *args, **kwargs):
+    class Meta:
+        attrs = {
+            'class': 'table table-hover table-headings',
+        }
+
+    def __init__(self, *args, columns=None, **kwargs):
         super().__init__(*args, **kwargs)
 
         # Set default empty_text if none was provided
         if self.empty_text is None:
             self.empty_text = 'No {} found'.format(self._meta.model._meta.verbose_name_plural)
 
-    class Meta:
-        attrs = {
-            'class': 'table table-hover table-headings',
-        }
+        # Hide non-default columns
+        default_columns = getattr(self.Meta, 'default_columns', list())
+        if default_columns:
+            for column in self.columns:
+                if column.name not in default_columns:
+                    self.columns.hide(column.name)
+
+        # Apply custom column ordering
+        if columns is not None:
+            pk = self.base_columns.pop('pk', None)
+            actions = self.base_columns.pop('actions', None)
+
+            for name, column in self.base_columns.items():
+                if name in columns:
+                    self.columns.show(name)
+                else:
+                    self.columns.hide(name)
+            self.sequence = columns
+
+            # Always include PK and actions column, if defined on the table
+            if pk:
+                self.base_columns['pk'] = pk
+                self.sequence.insert(0, 'pk')
+            if actions:
+                self.base_columns['actions'] = actions
+                self.sequence.append('actions')
+
+        # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
+        if isinstance(self.data, TableQuerysetData):
+            model = getattr(self.Meta, 'model')
+            prefetch_fields = []
+            for column in self.columns:
+                if column.visible:
+                    field_path = column.accessor.split('.')
+                    try:
+                        model_field = model._meta.get_field(field_path[0])
+                        if isinstance(model_field, ForeignKey):
+                            prefetch_fields.append('__'.join(field_path))
+                    except FieldDoesNotExist:
+                        pass
+            self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
+
+    @property
+    def configurable_columns(self):
+        selected_columns = [
+            (name, self.columns[name].verbose_name) for name in self.sequence if name not in ['pk', 'actions']
+        ]
+        available_columns = [
+            (name, column.verbose_name) for name, column in self.columns.items() if name not in self.sequence and name not in ['pk', 'actions']
+        ]
+        return selected_columns + available_columns
+
+    @property
+    def visible_columns(self):
+        return [name for name in self.sequence if self.columns[name].visible]
 
 
 class ToggleColumn(tables.CheckBoxColumn):

+ 20 - 2
netbox/utilities/views.py

@@ -24,7 +24,7 @@ from django_tables2 import RequestConfig
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.querysets import CustomFieldQueryset
 from utilities.exceptions import AbortTransaction
-from utilities.forms import BootstrapMixin, CSVDataField
+from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
 from utilities.utils import csv_format, prepare_cloned_fields
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
@@ -164,7 +164,8 @@ class ObjectListView(View):
             permissions[action] = request.user.has_perm(perm_name)
 
         # Construct the table based on the user's permissions
-        table = self.table(self.queryset)
+        columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
+        table = self.table(self.queryset, columns=columns)
         if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
             table.columns.show('pk')
 
@@ -180,12 +181,29 @@ class ObjectListView(View):
             'table': table,
             'permissions': permissions,
             'action_buttons': self.action_buttons,
+            'table_config_form': TableConfigForm(table=table),
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
         }
         context.update(self.extra_context())
 
         return render(request, self.template_name, context)
 
+    def post(self, request):
+
+        # Update the user's table configuration
+        table = self.table(self.queryset)
+        form = TableConfigForm(table=table, data=request.POST)
+        preference_name = f"tables.{self.table.__name__}.columns"
+
+        if form.is_valid():
+            if 'set' in request.POST:
+                request.user.config.set(preference_name, form.cleaned_data['columns'], commit=True)
+            elif 'clear' in request.POST:
+                request.user.config.clear(preference_name, commit=True)
+            messages.success(request, "Your preferences have been updated.")
+
+        return redirect(request.get_full_path())
+
     def alter_queryset(self, request):
         # .all() is necessary to avoid caching queries
         return self.queryset.all()

+ 61 - 14
netbox/virtualization/tables.py

@@ -46,7 +46,9 @@ VIRTUALMACHINE_PRIMARY_IP = """
 class ClusterTypeTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    cluster_count = tables.Column(verbose_name='Clusters')
+    cluster_count = tables.Column(
+        verbose_name='Clusters'
+    )
     actions = tables.TemplateColumn(
         template_code=CLUSTERTYPE_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -55,7 +57,8 @@ class ClusterTypeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ClusterType
-        fields = ('pk', 'name', 'cluster_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 #
@@ -65,7 +68,9 @@ class ClusterTypeTable(BaseTable):
 class ClusterGroupTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    cluster_count = tables.Column(verbose_name='Clusters')
+    cluster_count = tables.Column(
+        verbose_name='Clusters'
+    )
     actions = tables.TemplateColumn(
         template_code=CLUSTERGROUP_ACTIONS,
         attrs={'td': {'class': 'text-right noprint'}},
@@ -74,7 +79,8 @@ class ClusterGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ClusterGroup
-        fields = ('pk', 'name', 'cluster_count', 'description', 'actions')
+        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions')
+        default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 #
@@ -84,10 +90,24 @@ class ClusterGroupTable(BaseTable):
 class ClusterTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
-    device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices')
-    vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs')
+    tenant = tables.LinkColumn(
+        viewname='tenancy:tenant',
+        args=[Accessor('tenant.slug')]
+    )
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    device_count = tables.Column(
+        accessor=Accessor('devices.count'),
+        orderable=False,
+        verbose_name='Devices'
+    )
+    vm_count = tables.Column(
+        accessor=Accessor('virtual_machines.count'),
+        orderable=False,
+        verbose_name='VMs'
+    )
 
     class Meta(BaseTable.Meta):
         model = Cluster
@@ -101,10 +121,19 @@ class ClusterTable(BaseTable):
 class VirtualMachineTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
-    cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
-    role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
+    status = tables.TemplateColumn(
+        template_code=VIRTUALMACHINE_STATUS
+    )
+    cluster = tables.LinkColumn(
+        viewname='virtualization:cluster',
+        args=[Accessor('cluster.pk')]
+    )
+    role = tables.TemplateColumn(
+        template_code=VIRTUALMACHINE_ROLE
+    )
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
 
     class Meta(BaseTable.Meta):
         model = VirtualMachine
@@ -112,13 +141,31 @@ class VirtualMachineTable(BaseTable):
 
 
 class VirtualMachineDetailTable(VirtualMachineTable):
+    primary_ip4 = tables.LinkColumn(
+        viewname='ipam:ipaddress',
+        args=[Accessor('primary_ip4.pk')],
+        verbose_name='IPv4 Address'
+    )
+    primary_ip6 = tables.LinkColumn(
+        viewname='ipam:ipaddress',
+        args=[Accessor('primary_ip6.pk')],
+        verbose_name='IPv6 Address'
+    )
     primary_ip = tables.TemplateColumn(
-        orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP
+        orderable=False,
+        verbose_name='IP Address',
+        template_code=VIRTUALMACHINE_PRIMARY_IP
     )
 
     class Meta(BaseTable.Meta):
         model = VirtualMachine
-        fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip')
+        fields = (
+            'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
+            'primary_ip6', 'primary_ip',
+        )
+        default_columns = (
+            'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
+        )
 
 
 #