mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-17 20:46:30 -06:00
Merge pull request #5929 from netbox-community/4999-table-export
Closes #4999: Support for dynamic table-based exports
This commit is contained in:
commit
d3974c9f44
@ -93,3 +93,7 @@ redis
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite
|
||||
svgwrite
|
||||
|
||||
# Tabular dataset library (for table-based exports)
|
||||
# https://github.com/jazzband/tablib
|
||||
tablib
|
||||
|
@ -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.
|
||||
|
||||
#### 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))
|
||||
|
||||
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.
|
||||
|
@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
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 .models import Circuit, CircuitType, Provider
|
||||
|
||||
@ -60,9 +60,7 @@ class CircuitTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
a_side = tables.Column(
|
||||
verbose_name='A Side'
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
|
||||
PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
||||
)
|
||||
from tenancy.tables import COL_TENANT
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
TagColumn, ToggleColumn,
|
||||
@ -109,9 +109,7 @@ class DeviceTable(BaseTable):
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -178,9 +176,7 @@ class DeviceImportTable(BaseTable):
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
@ -2,12 +2,12 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, Location, RackReservation, RackRole
|
||||
from tenancy.tables import COL_TENANT
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn,
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn,
|
||||
TagColumn, ToggleColumn, UtilizationColumn,
|
||||
)
|
||||
from .template_code import MPTT_LINK, LOCATION_ELEVATIONS, UTILIZATION_GRAPH
|
||||
from .template_code import LOCATION_ELEVATIONS
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
@ -24,11 +24,7 @@ __all__ = (
|
||||
|
||||
class LocationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
name = MPTTColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -79,9 +75,7 @@ class RackTable(BaseTable):
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = ColoredLabelColumn()
|
||||
u_height = tables.TemplateColumn(
|
||||
@ -104,13 +98,10 @@ class RackDetailTable(RackTable):
|
||||
url_params={'rack_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
get_utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
orderable=False,
|
||||
get_utilization = UtilizationColumn(
|
||||
verbose_name='Space'
|
||||
)
|
||||
get_power_utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
get_power_utilization = UtilizationColumn(
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
@ -143,9 +134,7 @@ class RackReservationTable(BaseTable):
|
||||
accessor=Accessor('rack__site'),
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
@ -1,9 +1,8 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
|
||||
from .template_code import MPTT_LINK
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn
|
||||
|
||||
__all__ = (
|
||||
'RegionTable',
|
||||
@ -17,11 +16,7 @@ __all__ = (
|
||||
|
||||
class RegionTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
name = MPTTColumn()
|
||||
site_count = tables.Column(
|
||||
verbose_name='Sites'
|
||||
)
|
||||
@ -46,9 +41,7 @@ class SiteTable(BaseTable):
|
||||
region = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
)
|
||||
|
@ -56,13 +56,6 @@ INTERFACE_TAGGED_VLANS = """
|
||||
{% 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 = """
|
||||
<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">
|
||||
@ -82,11 +75,6 @@ LOCATION_ELEVATIONS = """
|
||||
</a>
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
#
|
||||
# Device component buttons
|
||||
#
|
||||
|
@ -3,20 +3,16 @@ from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn,
|
||||
UtilizationColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% load helpers %}
|
||||
{% for i in record.parents|as_range %}
|
||||
@ -109,16 +105,6 @@ VLAN_MEMBER_TAGGED = """
|
||||
{% 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 %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
@ -130,9 +116,7 @@ class VRFTable(BaseTable):
|
||||
rd = tables.Column(
|
||||
verbose_name='RD'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
enforce_unique = BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
@ -163,9 +147,7 @@ class VRFTable(BaseTable):
|
||||
class RouteTargetTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
@ -208,9 +190,7 @@ class AggregateTable(BaseTable):
|
||||
prefix = tables.LinkColumn(
|
||||
verbose_name='Aggregate'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
date_added = tables.DateColumn(
|
||||
format="Y-m-d",
|
||||
verbose_name='Added'
|
||||
@ -225,8 +205,8 @@ class AggregateDetailTable(AggregateTable):
|
||||
child_count = tables.Column(
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
utilization = UtilizationColumn(
|
||||
accessor='get_utilization',
|
||||
orderable=False
|
||||
)
|
||||
tags = TagColumn(
|
||||
@ -279,9 +259,7 @@ class PrefixTable(BaseTable):
|
||||
template_code=VRF_LINK,
|
||||
verbose_name='VRF'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -308,13 +286,11 @@ class PrefixTable(BaseTable):
|
||||
|
||||
|
||||
class PrefixDetailTable(PrefixTable):
|
||||
utilization = tables.TemplateColumn(
|
||||
template_code=UTILIZATION_GRAPH,
|
||||
utilization = UtilizationColumn(
|
||||
accessor='get_utilization',
|
||||
orderable=False
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:prefix_list'
|
||||
)
|
||||
@ -347,9 +323,7 @@ class IPAddressTable(BaseTable):
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
@ -379,9 +353,7 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
orderable=False,
|
||||
verbose_name='NAT (Inside)'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
assigned = BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
@ -428,9 +400,7 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
verbose_name='VRF'
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
@ -480,9 +450,7 @@ class VLANTable(BaseTable):
|
||||
viewname='ipam:vlangroup_vlans',
|
||||
args=[Accessor('group__pk')]
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
@ -504,9 +472,7 @@ class VLANDetailTable(VLANTable):
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = TagColumn(
|
||||
url_name='ipam:vlan_list'
|
||||
)
|
||||
@ -564,9 +530,7 @@ class InterfaceVLANTable(BaseTable):
|
||||
accessor=Accessor('group__name'),
|
||||
verbose_name='Group'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
|
@ -15,6 +15,7 @@ from django.utils.http import is_safe_url
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import CustomField, ExportTemplate
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
@ -137,32 +138,35 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
if self.filterset:
|
||||
self.queryset = self.filterset(request.GET, self.queryset).qs
|
||||
|
||||
# Check for export template rendering
|
||||
if request.GET.get('export'):
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
|
||||
try:
|
||||
return et.render_to_response(self.queryset)
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
"There was an error rendering the selected export template ({}): {}".format(
|
||||
et.name, e
|
||||
# Check for export rendering (except for table-based)
|
||||
if 'export' in request.GET and request.GET['export'] != 'table':
|
||||
|
||||
# An export template has been specified
|
||||
if request.GET['export']:
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
try:
|
||||
return et.render_to_response(self.queryset)
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
"There was an error rendering the selected export template ({}): {}".format(
|
||||
et.name, e
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Check for YAML export support
|
||||
elif 'export' in request.GET and hasattr(model, 'to_yaml'):
|
||||
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
|
||||
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
# Check for YAML export support
|
||||
elif hasattr(model, 'to_yaml'):
|
||||
response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
|
||||
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
|
||||
# Fall back to built-in CSV formatting if export requested but no template specified
|
||||
elif 'export' in request.GET and hasattr(model, 'to_csv'):
|
||||
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
|
||||
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
# Fall back to built-in CSV formatting if export requested but no template specified
|
||||
elif 'export' in request.GET and hasattr(model, 'to_csv'):
|
||||
response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
|
||||
filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
|
||||
# Compile a dictionary indicating which permissions are available to the current user for this model
|
||||
permissions = {}
|
||||
@ -175,6 +179,22 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||
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
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
|
@ -1,22 +1,32 @@
|
||||
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
|
||||
|
||||
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 %}
|
||||
<a href="{{ record.tenant.get_absolute_url }}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
#
|
||||
# Table columns
|
||||
#
|
||||
|
||||
class TenantColumn(tables.TemplateColumn):
|
||||
"""
|
||||
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 %}
|
||||
—
|
||||
{% 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):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(
|
||||
template_code=MPTT_LINK,
|
||||
orderable=False,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
name = MPTTColumn()
|
||||
tenant_count = LinkedCountColumn(
|
||||
viewname='tenancy:tenant_list',
|
||||
url_params={'group': 'slug'},
|
||||
|
@ -133,6 +133,9 @@ class BooleanColumn(tables.Column):
|
||||
rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
|
||||
return mark_safe(rendered)
|
||||
|
||||
def value(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class ButtonsColumn(tables.TemplateColumn):
|
||||
"""
|
||||
@ -177,6 +180,10 @@ class ButtonsColumn(tables.TemplateColumn):
|
||||
|
||||
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({
|
||||
'buttons': buttons or self.buttons,
|
||||
'return_url_extra': return_url_extra,
|
||||
@ -201,6 +208,9 @@ class ChoiceFieldColumn(tables.Column):
|
||||
)
|
||||
return self.default
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class ColorColumn(tables.Column):
|
||||
"""
|
||||
@ -211,6 +221,9 @@ class ColorColumn(tables.Column):
|
||||
f'<span class="label color-block" style="background-color: #{value}"> </span>'
|
||||
)
|
||||
|
||||
def value(self, value):
|
||||
return f'#{value}'
|
||||
|
||||
|
||||
class ColoredLabelColumn(tables.TemplateColumn):
|
||||
"""
|
||||
@ -224,6 +237,9 @@ class ColoredLabelColumn(tables.TemplateColumn):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(template_code=self.template_code, *args, **kwargs)
|
||||
|
||||
def value(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class LinkedCountColumn(tables.Column):
|
||||
"""
|
||||
@ -247,6 +263,9 @@ class LinkedCountColumn(tables.Column):
|
||||
return mark_safe(f'<a href="{url}">{value}</a>')
|
||||
return value
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class TagColumn(tables.TemplateColumn):
|
||||
"""
|
||||
@ -265,3 +284,39 @@ class TagColumn(tables.TemplateColumn):
|
||||
template_code=self.template_code,
|
||||
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}%'
|
||||
|
@ -1,19 +1,16 @@
|
||||
{% if export_templates %}
|
||||
<div class="btn-group">
|
||||
<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>
|
||||
Export <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">Default format</a></li>
|
||||
<li class="divider"></li>
|
||||
{% for et in export_templates %}
|
||||
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export" class="btn btn-success">
|
||||
<span class="mdi mdi-database-export" aria-hidden="true"></span> Export
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="btn-group">
|
||||
<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>
|
||||
Export <span class="caret"></span>
|
||||
</button>
|
||||
<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>
|
||||
{% if export_templates %}
|
||||
<li class="divider"></li>
|
||||
{% for et in export_templates %}
|
||||
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -559,12 +559,6 @@ class ViewTestCases:
|
||||
# Try GET with model-level permission
|
||||
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=[])
|
||||
def test_list_objects_with_constrained_permission(self):
|
||||
instance1, instance2 = self._get_queryset().all()[:2]
|
||||
@ -590,6 +584,23 @@ class ViewTestCases:
|
||||
self.assertIn(instance1.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):
|
||||
"""
|
||||
Create multiple instances using a single form. Expects the creation of three new instances by default.
|
||||
|
@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.tables.devices import BaseInterfaceTable
|
||||
from tenancy.tables import COL_TENANT
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn,
|
||||
)
|
||||
@ -107,9 +107,7 @@ class VirtualMachineTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
role = ColoredLabelColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualMachine
|
||||
|
@ -21,3 +21,4 @@ psycopg2-binary==2.8.6
|
||||
pycryptodome==3.9.9
|
||||
PyYAML==5.3.1
|
||||
svgwrite==1.4
|
||||
tablib==3.0.0
|
||||
|
Loading…
Reference in New Issue
Block a user