Merge pull request #5929 from netbox-community/4999-table-export

Closes #4999: Support for dynamic table-based exports
This commit is contained in:
Jeremy Stretch 2021-03-05 09:30:06 -05:00 committed by GitHub
commit 6afb7424a9
15 changed files with 204 additions and 178 deletions

View File

@ -93,3 +93,7 @@ redis
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite # https://github.com/mozman/svgwrite
svgwrite svgwrite
# Tabular dataset library (for table-based exports)
# https://github.com/jazzband/tablib
tablib

View File

@ -16,6 +16,12 @@ In addition to the new `mark_connected` boolean field, the REST API representati
Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this. Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this.
#### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999))
When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns.
The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12.
#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) #### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913))
The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor.

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider from .models import Circuit, CircuitType, Provider
@ -60,9 +60,7 @@ class CircuitTable(BaseTable):
linkify=True linkify=True
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
a_side = tables.Column( a_side = tables.Column(
verbose_name='A Side' verbose_name='A Side'
) )

View File

@ -5,7 +5,7 @@ from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
PowerOutlet, PowerPort, RearPort, VirtualChassis, PowerOutlet, PowerPort, RearPort, VirtualChassis,
) )
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn, TagColumn, ToggleColumn,
@ -109,9 +109,7 @@ class DeviceTable(BaseTable):
template_code=DEVICE_LINK template_code=DEVICE_LINK
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
@ -178,9 +176,7 @@ class DeviceImportTable(BaseTable):
template_code=DEVICE_LINK template_code=DEVICE_LINK
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )

View File

@ -2,12 +2,12 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Rack, Location, RackReservation, RackRole from dcim.models import Rack, Location, RackReservation, RackRole
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
ToggleColumn, TagColumn, ToggleColumn, UtilizationColumn,
) )
from .template_code import MPTT_LINK, LOCATION_ELEVATIONS, UTILIZATION_GRAPH from .template_code import LOCATION_ELEVATIONS
__all__ = ( __all__ = (
'RackTable', 'RackTable',
@ -24,11 +24,7 @@ __all__ = (
class LocationTable(BaseTable): class LocationTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.TemplateColumn( name = MPTTColumn()
template_code=MPTT_LINK,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
@ -79,9 +75,7 @@ class RackTable(BaseTable):
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
role = ColoredLabelColumn() role = ColoredLabelColumn()
u_height = tables.TemplateColumn( u_height = tables.TemplateColumn(
@ -104,13 +98,10 @@ class RackDetailTable(RackTable):
url_params={'rack_id': 'pk'}, url_params={'rack_id': 'pk'},
verbose_name='Devices' verbose_name='Devices'
) )
get_utilization = tables.TemplateColumn( get_utilization = UtilizationColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
verbose_name='Space' verbose_name='Space'
) )
get_power_utilization = tables.TemplateColumn( get_power_utilization = UtilizationColumn(
template_code=UTILIZATION_GRAPH,
orderable=False, orderable=False,
verbose_name='Power' verbose_name='Power'
) )
@ -143,9 +134,7 @@ class RackReservationTable(BaseTable):
accessor=Accessor('rack__site'), accessor=Accessor('rack__site'),
linkify=True linkify=True
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
rack = tables.Column( rack = tables.Column(
linkify=True linkify=True
) )

View File

@ -1,9 +1,8 @@
import django_tables2 as tables import django_tables2 as tables
from dcim.models import Region, Site from dcim.models import Region, Site
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn
from .template_code import MPTT_LINK
__all__ = ( __all__ = (
'RegionTable', 'RegionTable',
@ -17,11 +16,7 @@ __all__ = (
class RegionTable(BaseTable): class RegionTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.TemplateColumn( name = MPTTColumn()
template_code=MPTT_LINK,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site_count = tables.Column( site_count = tables.Column(
verbose_name='Sites' verbose_name='Sites'
) )
@ -46,9 +41,7 @@ class SiteTable(BaseTable):
region = tables.Column( region = tables.Column(
linkify=True linkify=True
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
tags = TagColumn( tags = TagColumn(
url_name='dcim:site_list' url_name='dcim:site_list'
) )

View File

@ -56,13 +56,6 @@ INTERFACE_TAGGED_VLANS = """
{% endif %} {% endif %}
""" """
MPTT_LINK = """
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
POWERFEED_CABLE = """ POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a> <a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"> <a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace">
@ -82,11 +75,6 @@ LOCATION_ELEVATIONS = """
</a> </a>
""" """
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph value %}
"""
# #
# Device component buttons # Device component buttons
# #

View File

@ -3,20 +3,16 @@ from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn,
UtilizationColumn,
) )
from virtualization.models import VMInterface from virtualization.models import VMInterface
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>') AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
UTILIZATION_GRAPH = """
{% load helpers %}
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
"""
PREFIX_LINK = """ PREFIX_LINK = """
{% load helpers %} {% load helpers %}
{% for i in record.parents|as_range %} {% for i in record.parents|as_range %}
@ -109,16 +105,6 @@ VLAN_MEMBER_TAGGED = """
{% endif %} {% endif %}
""" """
TENANT_LINK = """
{% if record.tenant %}
<a href="{{ record.tenant.get_absolute_url }}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
{% elif record.vrf.tenant %}
<a href="{{ record.vrf.tenant.get_absolute_url }}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
{% else %}
&mdash;
{% endif %}
"""
# #
# VRFs # VRFs
@ -130,9 +116,7 @@ class VRFTable(BaseTable):
rd = tables.Column( rd = tables.Column(
verbose_name='RD' verbose_name='RD'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
enforce_unique = BooleanColumn( enforce_unique = BooleanColumn(
verbose_name='Unique' verbose_name='Unique'
) )
@ -163,9 +147,7 @@ class VRFTable(BaseTable):
class RouteTargetTable(BaseTable): class RouteTargetTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn() name = tables.LinkColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:vrf_list' url_name='ipam:vrf_list'
) )
@ -208,9 +190,7 @@ class AggregateTable(BaseTable):
prefix = tables.LinkColumn( prefix = tables.LinkColumn(
verbose_name='Aggregate' verbose_name='Aggregate'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=TENANT_LINK
)
date_added = tables.DateColumn( date_added = tables.DateColumn(
format="Y-m-d", format="Y-m-d",
verbose_name='Added' verbose_name='Added'
@ -225,8 +205,8 @@ class AggregateDetailTable(AggregateTable):
child_count = tables.Column( child_count = tables.Column(
verbose_name='Prefixes' verbose_name='Prefixes'
) )
utilization = tables.TemplateColumn( utilization = UtilizationColumn(
template_code=UTILIZATION_GRAPH, accessor='get_utilization',
orderable=False orderable=False
) )
tags = TagColumn( tags = TagColumn(
@ -279,9 +259,7 @@ class PrefixTable(BaseTable):
template_code=VRF_LINK, template_code=VRF_LINK,
verbose_name='VRF' verbose_name='VRF'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=TENANT_LINK
)
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
@ -308,13 +286,11 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable): class PrefixDetailTable(PrefixTable):
utilization = tables.TemplateColumn( utilization = UtilizationColumn(
template_code=UTILIZATION_GRAPH, accessor='get_utilization',
orderable=False orderable=False
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:prefix_list' url_name='ipam:prefix_list'
) )
@ -347,9 +323,7 @@ class IPAddressTable(BaseTable):
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
role = ChoiceFieldColumn() role = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=TENANT_LINK
)
assigned_object = tables.Column( assigned_object = tables.Column(
linkify=True, linkify=True,
orderable=False, orderable=False,
@ -379,9 +353,7 @@ class IPAddressDetailTable(IPAddressTable):
orderable=False, orderable=False,
verbose_name='NAT (Inside)' verbose_name='NAT (Inside)'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
assigned = BooleanColumn( assigned = BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
verbose_name='Assigned' verbose_name='Assigned'
@ -428,9 +400,7 @@ class InterfaceIPAddressTable(BaseTable):
verbose_name='VRF' verbose_name='VRF'
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=TENANT_LINK
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
@ -480,9 +450,7 @@ class VLANTable(BaseTable):
viewname='ipam:vlangroup_vlans', viewname='ipam:vlangroup_vlans',
args=[Accessor('group__pk')] args=[Accessor('group__pk')]
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
status = ChoiceFieldColumn( status = ChoiceFieldColumn(
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
@ -504,9 +472,7 @@ class VLANDetailTable(VLANTable):
orderable=False, orderable=False,
verbose_name='Prefixes' verbose_name='Prefixes'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:vlan_list' url_name='ipam:vlan_list'
) )
@ -564,9 +530,7 @@ class InterfaceVLANTable(BaseTable):
accessor=Accessor('group__name'), accessor=Accessor('group__name'),
verbose_name='Group' verbose_name='Group'
) )
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
role = tables.TemplateColumn( role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK template_code=VLAN_ROLE_LINK

View File

@ -15,6 +15,7 @@ from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from django_tables2.export import TableExport
from extras.models import CustomField, ExportTemplate from extras.models import CustomField, ExportTemplate
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
@ -137,32 +138,35 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
if self.filterset: if self.filterset:
self.queryset = self.filterset(request.GET, self.queryset).qs self.queryset = self.filterset(request.GET, self.queryset).qs
# Check for export template rendering # Check for export rendering (except for table-based)
if request.GET.get('export'): if 'export' in request.GET and request.GET['export'] != 'table':
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
try: # An export template has been specified
return et.render_to_response(self.queryset) if request.GET['export']:
except Exception as e: et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
messages.error( try:
request, return et.render_to_response(self.queryset)
"There was an error rendering the selected export template ({}): {}".format( except Exception as e:
et.name, e messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
)
) )
)
# Check for YAML export support # Check for YAML export support
elif 'export' in request.GET and hasattr(model, 'to_yaml'): elif hasattr(model, 'to_yaml'):
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml') response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response return response
# Fall back to built-in CSV formatting if export requested but no template specified # Fall back to built-in CSV formatting if export requested but no template specified
elif 'export' in request.GET and hasattr(model, 'to_csv'): elif 'export' in request.GET and hasattr(model, 'to_csv'):
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv') response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural) filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response return response
# Compile a dictionary indicating which permissions are available to the current user for this model # Compile a dictionary indicating which permissions are available to the current user for this model
permissions = {} permissions = {}
@ -175,6 +179,22 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk') table.columns.show('pk')
# Handle table-based export
if request.GET.get('export') == 'table':
exclude_columns = {'pk'}
exclude_columns.update({
col for col in table.base_columns if col not in table.visible_columns
})
exporter = TableExport(
export_format=TableExport.CSV,
table=table,
exclude_columns=exclude_columns,
dataset_kwargs={},
)
return exporter.response(
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
)
# Apply the request context # Apply the request context
paginate = { paginate = {
'paginator_class': EnhancedPaginator, 'paginator_class': EnhancedPaginator,

View File

@ -1,22 +1,32 @@
import django_tables2 as tables import django_tables2 as tables
from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
MPTT_LINK = """
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
COL_TENANT = """ #
{% if record.tenant %} # Table columns
<a href="{{ record.tenant.get_absolute_url }}" title="{{ record.tenant.description }}">{{ record.tenant }}</a> #
{% else %}
&mdash; class TenantColumn(tables.TemplateColumn):
{% endif %} """
""" Render a colored label (e.g. for DeviceRoles).
"""
template_code = """
{% if record.tenant %}
<a href="{{ record.tenant.get_absolute_url }}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
{% elif record.vrf.tenant %}
<a href="{{ record.vrf.tenant.get_absolute_url }}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
{% else %}
&mdash;
{% endif %}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value)
# #
@ -25,11 +35,7 @@ COL_TENANT = """
class TenantGroupTable(BaseTable): class TenantGroupTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.TemplateColumn( name = MPTTColumn()
template_code=MPTT_LINK,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
tenant_count = LinkedCountColumn( tenant_count = LinkedCountColumn(
viewname='tenancy:tenant_list', viewname='tenancy:tenant_list',
url_params={'group': 'slug'}, url_params={'group': 'slug'},

View File

@ -133,6 +133,9 @@ class BooleanColumn(tables.Column):
rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>' rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
return mark_safe(rendered) return mark_safe(rendered)
def value(self, value):
return str(value)
class ButtonsColumn(tables.TemplateColumn): class ButtonsColumn(tables.TemplateColumn):
""" """
@ -177,6 +180,10 @@ class ButtonsColumn(tables.TemplateColumn):
super().__init__(template_code=template_code, *args, **kwargs) super().__init__(template_code=template_code, *args, **kwargs)
# Exclude from export by default
if 'exclude_from_export' not in kwargs:
self.exclude_from_export = True
self.extra_context.update({ self.extra_context.update({
'buttons': buttons or self.buttons, 'buttons': buttons or self.buttons,
'return_url_extra': return_url_extra, 'return_url_extra': return_url_extra,
@ -201,6 +208,9 @@ class ChoiceFieldColumn(tables.Column):
) )
return self.default return self.default
def value(self, value):
return value
class ColorColumn(tables.Column): class ColorColumn(tables.Column):
""" """
@ -211,6 +221,9 @@ class ColorColumn(tables.Column):
f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>' f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>'
) )
def value(self, value):
return f'#{value}'
class ColoredLabelColumn(tables.TemplateColumn): class ColoredLabelColumn(tables.TemplateColumn):
""" """
@ -224,6 +237,9 @@ class ColoredLabelColumn(tables.TemplateColumn):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs) super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value)
class LinkedCountColumn(tables.Column): class LinkedCountColumn(tables.Column):
""" """
@ -247,6 +263,9 @@ class LinkedCountColumn(tables.Column):
return mark_safe(f'<a href="{url}">{value}</a>') return mark_safe(f'<a href="{url}">{value}</a>')
return value return value
def value(self, value):
return value
class TagColumn(tables.TemplateColumn): class TagColumn(tables.TemplateColumn):
""" """
@ -265,3 +284,39 @@ class TagColumn(tables.TemplateColumn):
template_code=self.template_code, template_code=self.template_code,
extra_context={'url_name': url_name} extra_context={'url_name': url_name}
) )
def value(self, value):
return ",".join([tag.name for tag in value.all()])
class MPTTColumn(tables.TemplateColumn):
"""
Display a nested hierarchy for MPTT-enabled models.
"""
template_code = """{% for i in record.get_ancestors %}<i class="mdi mdi-circle-small"></i>{% endfor %}""" \
"""<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>"""
def __init__(self, *args, **kwargs):
super().__init__(
template_code=self.template_code,
orderable=False,
attrs={'td': {'class': 'text-nowrap'}},
*args,
**kwargs
)
def value(self, value):
return value
class UtilizationColumn(tables.TemplateColumn):
"""
Display a colored utilization bar graph.
"""
template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return f'{value}%'

View File

@ -1,19 +1,16 @@
{% if export_templates %} <div class="btn-group">
<div class="btn-group"> <button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <span class="mdi mdi-database-export" aria-hidden="true"></span>
<span class="mdi mdi-database-export" aria-hidden="true"></span> Export <span class="caret"></span>
Export <span class="caret"></span> </button>
</button> <ul class="dropdown-menu dropdown-menu-right">
<ul class="dropdown-menu dropdown-menu-right"> <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export=table">Current view</a></li>
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">Default format</a></li> <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">Default format</a></li>
<li class="divider"></li> {% if export_templates %}
{% for et in export_templates %} <li class="divider"></li>
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li> {% for et in export_templates %}
{% endfor %} <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
</ul> {% endfor %}
</div> {% endif %}
{% else %} </ul>
<a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export" class="btn btn-success"> </div>
<span class="mdi mdi-database-export" aria-hidden="true"></span> Export
</a>
{% endif %}

View File

@ -559,12 +559,6 @@ class ViewTestCases:
# Try GET with model-level permission # Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('list')), 200) self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
# Built-in CSV export
if hasattr(self.model, 'csv_headers'):
response = self.client.get('{}?export'.format(self._get_url('list')))
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv')
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_with_constrained_permission(self): def test_list_objects_with_constrained_permission(self):
instance1, instance2 = self._get_queryset().all()[:2] instance1, instance2 = self._get_queryset().all()[:2]
@ -590,6 +584,23 @@ class ViewTestCases:
self.assertIn(instance1.get_absolute_url(), content) self.assertIn(instance1.get_absolute_url(), content)
self.assertNotIn(instance2.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_export_objects(self):
url = self._get_url('list')
# Test default CSV export
response = self.client.get(f'{url}?export')
self.assertHttpStatus(response, 200)
if hasattr(self.model, 'csv_headers'):
self.assertEqual(response.get('Content-Type'), 'text/csv')
content = response.content.decode('utf-8')
self.assertEqual(content.splitlines()[0], ','.join(self.model.csv_headers))
# Test table-based export
response = self.client.get(f'{url}?export=table')
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
class CreateMultipleObjectsViewTestCase(ModelViewTestCase): class CreateMultipleObjectsViewTestCase(ModelViewTestCase):
""" """
Create multiple instances using a single form. Expects the creation of three new instances by default. Create multiple instances using a single form. Expects the creation of three new instances by default.

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from dcim.tables.devices import BaseInterfaceTable from dcim.tables.devices import BaseInterfaceTable
from tenancy.tables import COL_TENANT from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn,
) )
@ -107,9 +107,7 @@ class VirtualMachineTable(BaseTable):
linkify=True linkify=True
) )
role = ColoredLabelColumn() role = ColoredLabelColumn()
tenant = tables.TemplateColumn( tenant = TenantColumn()
template_code=COL_TENANT
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VirtualMachine model = VirtualMachine

View File

@ -21,3 +21,4 @@ psycopg2-binary==2.8.6
pycryptodome==3.9.9 pycryptodome==3.9.9
PyYAML==5.3.1 PyYAML==5.3.1
svgwrite==1.4 svgwrite==1.4
tablib==3.0.0