mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Closes #8469: Move BaseTable, columns to netbox core app
This commit is contained in:
7
netbox/utilities/tables.py
Normal file
7
netbox/utilities/tables.py
Normal file
@@ -0,0 +1,7 @@
|
||||
def linkify_phone(value):
|
||||
"""
|
||||
Render a telephone number as a hyperlink.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return f"tel:{value}"
|
@@ -1,39 +0,0 @@
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from .columns import *
|
||||
from .tables import *
|
||||
|
||||
|
||||
def configure_table(table, request):
|
||||
"""
|
||||
Paginate a table given a request context.
|
||||
"""
|
||||
# Save ordering preference
|
||||
if request.user.is_authenticated:
|
||||
table_name = table.__class__.__name__
|
||||
if table.prefixed_order_by_field in request.GET:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
ordering = request.GET.getlist(table.prefixed_order_by_field)
|
||||
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
|
||||
# If no ordering has been specified, set the preferred ordering (if any).
|
||||
table.order_by = ordering
|
||||
|
||||
# Paginate the table results
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(table)
|
||||
|
||||
|
||||
#
|
||||
# Callables
|
||||
#
|
||||
|
||||
def linkify_phone(value):
|
||||
if value is None:
|
||||
return None
|
||||
return f"tel:{value}"
|
@@ -1,420 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from utilities.utils import content_type_identifier, content_type_name
|
||||
|
||||
__all__ = (
|
||||
'ActionsColumn',
|
||||
'BooleanColumn',
|
||||
'ChoiceFieldColumn',
|
||||
'ColorColumn',
|
||||
'ColoredLabelColumn',
|
||||
'ContentTypeColumn',
|
||||
'ContentTypesColumn',
|
||||
'CustomFieldColumn',
|
||||
'CustomLinkColumn',
|
||||
'LinkedCountColumn',
|
||||
'MarkdownColumn',
|
||||
'MPTTColumn',
|
||||
'TagColumn',
|
||||
'TemplateColumn',
|
||||
'ToggleColumn',
|
||||
'UtilizationColumn',
|
||||
)
|
||||
|
||||
|
||||
class ToggleColumn(tables.CheckBoxColumn):
|
||||
"""
|
||||
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
default = kwargs.pop('default', '')
|
||||
visible = kwargs.pop('visible', False)
|
||||
if 'attrs' not in kwargs:
|
||||
kwargs['attrs'] = {
|
||||
'td': {
|
||||
'class': 'min-width',
|
||||
},
|
||||
'input': {
|
||||
'class': 'form-check-input'
|
||||
}
|
||||
}
|
||||
super().__init__(*args, default=default, visible=visible, **kwargs)
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
return mark_safe('<input type="checkbox" class="toggle form-check-input" title="Toggle All" />')
|
||||
|
||||
|
||||
class BooleanColumn(tables.Column):
|
||||
"""
|
||||
Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
|
||||
character.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value:
|
||||
rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
|
||||
elif value is None:
|
||||
rendered = '<span class="text-muted">—</span>'
|
||||
else:
|
||||
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 TemplateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
|
||||
"""
|
||||
PLACEHOLDER = mark_safe('—')
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
ret = super().render(*args, **kwargs)
|
||||
if not ret.strip():
|
||||
return self.PLACEHOLDER
|
||||
return ret
|
||||
|
||||
def value(self, **kwargs):
|
||||
ret = super().value(**kwargs)
|
||||
if ret == self.PLACEHOLDER:
|
||||
return ''
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionsItem:
|
||||
title: str
|
||||
icon: str
|
||||
permission: Optional[str] = None
|
||||
|
||||
|
||||
class ActionsColumn(tables.Column):
|
||||
"""
|
||||
A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include
|
||||
additional buttons rendered from a template string.
|
||||
|
||||
:param sequence: The ordered list of dropdown menu items to include
|
||||
:param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
|
||||
"""
|
||||
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||
empty_values = ()
|
||||
actions = {
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
|
||||
'changelog': ActionsItem('Changelog', 'history'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.extra_buttons = extra_buttons
|
||||
|
||||
# Determine which actions to enable
|
||||
self.actions = {
|
||||
name: self.actions[name] for name in sequence
|
||||
}
|
||||
|
||||
def header(self):
|
||||
return ''
|
||||
|
||||
def render(self, record, table, **kwargs):
|
||||
# Skip dummy records (e.g. available VLANs) or those with no actions
|
||||
if not getattr(record, 'pk', None) or not self.actions:
|
||||
return ''
|
||||
|
||||
model = table.Meta.model
|
||||
viewname_base = f'{model._meta.app_label}:{model._meta.model_name}'
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={request.path}' if request else ''
|
||||
|
||||
links = []
|
||||
user = getattr(request, 'user', AnonymousUser())
|
||||
for action, attrs in self.actions.items():
|
||||
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||
if attrs.permission is None or user.has_perm(permission):
|
||||
url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk})
|
||||
links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
|
||||
|
||||
if not links:
|
||||
return ''
|
||||
|
||||
menu = f'<span class="dropdown">' \
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
|
||||
f'<i class="mdi mdi-wrench"></i></a>' \
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
|
||||
# Render any extra buttons from template code
|
||||
if self.extra_buttons:
|
||||
template = Template(self.extra_buttons)
|
||||
context = getattr(table, "context", Context())
|
||||
context.update({'record': record})
|
||||
menu = template.render(context) + menu
|
||||
|
||||
return mark_safe(menu)
|
||||
|
||||
|
||||
class ChoiceFieldColumn(tables.Column):
|
||||
"""
|
||||
Render a ChoiceField value inside a <span> indicating a particular CSS class. This is useful for displaying colored
|
||||
choices. The CSS class is derived by calling .get_FOO_class() on the row record.
|
||||
"""
|
||||
def render(self, record, bound_column, value):
|
||||
if value:
|
||||
name = bound_column.name
|
||||
css_class = getattr(record, f'get_{name}_class')()
|
||||
label = getattr(record, f'get_{name}_display')()
|
||||
return mark_safe(
|
||||
f'<span class="badge bg-{css_class}">{label}</span>'
|
||||
)
|
||||
return self.default
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class ContentTypeColumn(tables.Column):
|
||||
"""
|
||||
Display a ContentType instance.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return content_type_name(value)
|
||||
|
||||
def value(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return content_type_identifier(value)
|
||||
|
||||
|
||||
class ContentTypesColumn(tables.ManyToManyColumn):
|
||||
"""
|
||||
Display a list of ContentType instances.
|
||||
"""
|
||||
def __init__(self, separator=None, *args, **kwargs):
|
||||
# Use a line break as the default separator
|
||||
if separator is None:
|
||||
separator = mark_safe('<br />')
|
||||
super().__init__(separator=separator, *args, **kwargs)
|
||||
|
||||
def transform(self, obj):
|
||||
return content_type_name(obj)
|
||||
|
||||
def value(self, value):
|
||||
return ','.join([
|
||||
content_type_identifier(ct) for ct in self.filter(value)
|
||||
])
|
||||
|
||||
|
||||
class ColorColumn(tables.Column):
|
||||
"""
|
||||
Display a color (#RRGGBB).
|
||||
"""
|
||||
def render(self, value):
|
||||
return mark_safe(
|
||||
f'<span class="color-label" style="background-color: #{value}"> </span>'
|
||||
)
|
||||
|
||||
def value(self, value):
|
||||
return f'#{value}'
|
||||
|
||||
|
||||
class ColoredLabelColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Render a colored label (e.g. for DeviceRoles).
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% if value %}
|
||||
<span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
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):
|
||||
"""
|
||||
Render a count of related objects linked to a filtered URL.
|
||||
|
||||
:param viewname: The view name to use for URL resolution
|
||||
:param view_kwargs: Additional kwargs to pass for URL resolution (optional)
|
||||
:param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
|
||||
"""
|
||||
def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
|
||||
self.viewname = viewname
|
||||
self.view_kwargs = view_kwargs or {}
|
||||
self.url_params = url_params
|
||||
super().__init__(*args, default=default, **kwargs)
|
||||
|
||||
def render(self, record, value):
|
||||
if value:
|
||||
url = reverse(self.viewname, kwargs=self.view_kwargs)
|
||||
if self.url_params:
|
||||
url += '?' + '&'.join([
|
||||
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
|
||||
for k, v in self.url_params.items()
|
||||
])
|
||||
return mark_safe(f'<a href="{url}">{value}</a>')
|
||||
return value
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class TagColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Display a list of tags assigned to the object.
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% for tag in value.all %}
|
||||
{% tag tag url_name=url_name %}
|
||||
{% empty %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
def __init__(self, url_name=None):
|
||||
super().__init__(
|
||||
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 CustomFieldColumn(tables.Column):
|
||||
"""
|
||||
Display custom fields in the appropriate format.
|
||||
"""
|
||||
def __init__(self, customfield, *args, **kwargs):
|
||||
self.customfield = customfield
|
||||
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customfield.label or customfield.name
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def render(self, value):
|
||||
if isinstance(value, list):
|
||||
return ', '.join(v for v in value)
|
||||
elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
# Linkify custom URLs
|
||||
return mark_safe(f'<a href="{value}">{value}</a>')
|
||||
if value is not None:
|
||||
obj = self.customfield.deserialize(value)
|
||||
if hasattr(obj, 'get_absolute_url'):
|
||||
return mark_safe(f'<a href="{obj.get_absolute_url}">{obj}</a>')
|
||||
return obj
|
||||
return self.default
|
||||
|
||||
|
||||
class CustomLinkColumn(tables.Column):
|
||||
"""
|
||||
Render a custom links as a table column.
|
||||
"""
|
||||
def __init__(self, customlink, *args, **kwargs):
|
||||
self.customlink = customlink
|
||||
kwargs['accessor'] = Accessor('pk')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customlink.name
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def render(self, record):
|
||||
try:
|
||||
rendered = self.customlink.render({'obj': record})
|
||||
if rendered:
|
||||
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
|
||||
return ''
|
||||
|
||||
def value(self, record):
|
||||
try:
|
||||
rendered = self.customlink.render({'obj': record})
|
||||
if rendered:
|
||||
return rendered['link']
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class MPTTColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Display a nested hierarchy for MPTT-enabled models.
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% for i in record.level|as_range %}<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}%'
|
||||
|
||||
|
||||
class MarkdownColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Render a Markdown string.
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% if value %}
|
||||
{{ value|render_markdown }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
template_code=self.template_code
|
||||
)
|
||||
|
||||
def value(self, value):
|
||||
return value
|
@@ -1,139 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models.fields.related import RelatedField
|
||||
from django_tables2.data import TableQuerysetData
|
||||
|
||||
from extras.models import CustomField, CustomLink
|
||||
from . import columns
|
||||
|
||||
__all__ = (
|
||||
'BaseTable',
|
||||
)
|
||||
|
||||
|
||||
class BaseTable(tables.Table):
|
||||
"""
|
||||
Default table for object lists
|
||||
|
||||
:param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
|
||||
"""
|
||||
id = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
)
|
||||
actions = columns.ActionsColumn()
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-hover object-list',
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, extra_columns=None, **kwargs):
|
||||
if extra_columns is None:
|
||||
extra_columns = []
|
||||
|
||||
# Add custom field & custom link columns
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(content_types=content_type)
|
||||
extra_columns.extend([
|
||||
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
|
||||
])
|
||||
custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
|
||||
extra_columns.extend([
|
||||
(f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
|
||||
])
|
||||
|
||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
||||
|
||||
# Set default empty_text if none was provided
|
||||
if self.empty_text is None:
|
||||
self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
|
||||
|
||||
# Hide non-default columns (except for actions)
|
||||
default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), 'actions']
|
||||
for column in self.columns:
|
||||
if column.name not in default_columns:
|
||||
self.columns.hide(column.name)
|
||||
|
||||
# Apply custom column ordering for user
|
||||
if user is not None and not isinstance(user, AnonymousUser):
|
||||
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
|
||||
if selected_columns:
|
||||
|
||||
# Show only persistent or selected columns
|
||||
for name, column in self.columns.items():
|
||||
if name in ['pk', 'actions', *selected_columns]:
|
||||
self.columns.show(name)
|
||||
else:
|
||||
self.columns.hide(name)
|
||||
|
||||
# Rearrange the sequence to list selected columns first, followed by all remaining columns
|
||||
# TODO: There's probably a more clever way to accomplish this
|
||||
self.sequence = [
|
||||
*[c for c in selected_columns if c in self.columns.names()],
|
||||
*[c for c in self.columns.names() if c not in selected_columns]
|
||||
]
|
||||
|
||||
# PK column should always come first
|
||||
if 'pk' in self.sequence:
|
||||
self.sequence.remove('pk')
|
||||
self.sequence.insert(0, 'pk')
|
||||
|
||||
# Actions column should always come last
|
||||
if 'actions' in self.sequence:
|
||||
self.sequence.remove('actions')
|
||||
self.sequence.append('actions')
|
||||
|
||||
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
|
||||
if isinstance(self.data, TableQuerysetData):
|
||||
|
||||
prefetch_fields = []
|
||||
for column in self.columns:
|
||||
if column.visible:
|
||||
model = getattr(self.Meta, 'model')
|
||||
accessor = column.accessor
|
||||
prefetch_path = []
|
||||
for field_name in accessor.split(accessor.SEPARATOR):
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
break
|
||||
if isinstance(field, RelatedField):
|
||||
# Follow ForeignKeys to the related model
|
||||
prefetch_path.append(field_name)
|
||||
model = field.remote_field.model
|
||||
elif isinstance(field, GenericForeignKey):
|
||||
# Can't prefetch beyond a GenericForeignKey
|
||||
prefetch_path.append(field_name)
|
||||
break
|
||||
if prefetch_path:
|
||||
prefetch_fields.append('__'.join(prefetch_path))
|
||||
self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
|
||||
|
||||
def _get_columns(self, visible=True):
|
||||
columns = []
|
||||
for name, column in self.columns.items():
|
||||
if column.visible == visible and name not in ['pk', 'actions']:
|
||||
columns.append((name, column.verbose_name))
|
||||
return columns
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
|
||||
@property
|
||||
def selected_columns(self):
|
||||
return self._get_columns(visible=True)
|
||||
|
||||
@property
|
||||
def objects_count(self):
|
||||
"""
|
||||
Return the total number of real objects represented by the Table. This is useful when dealing with
|
||||
prefixes/IP addresses/etc., where some table rows may represent available address space.
|
||||
"""
|
||||
if not hasattr(self, '_objects_count'):
|
||||
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
|
||||
return self._objects_count
|
@@ -2,12 +2,12 @@ from django.template import Context, Template
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from utilities.tables import BaseTable, TagColumn
|
||||
from netbox.tables import BaseTable, columns
|
||||
from utilities.testing import create_tags
|
||||
|
||||
|
||||
class TagColumnTable(BaseTable):
|
||||
tags = TagColumn(url_name='dcim:site_list')
|
||||
tags = columns.TagColumn(url_name='dcim:site_list')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
|
Reference in New Issue
Block a user