674 lines
22 KiB
Python
674 lines
22 KiB
Python
from dataclasses import dataclass
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
|
|
import django_tables2 as tables
|
|
from django.conf import settings
|
|
from django.contrib.auth.context_processors import auth
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.db.models import DateField, DateTimeField
|
|
from django.template import Context, Template
|
|
from django.urls import reverse
|
|
from django.utils.dateparse import parse_date
|
|
from django.utils.html import escape
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django_tables2.columns import library
|
|
from django_tables2.utils import Accessor
|
|
|
|
from extras.choices import CustomFieldTypeChoices
|
|
from utilities.object_types import object_type_identifier, object_type_name
|
|
from utilities.permissions import get_permission_for_model
|
|
from utilities.templatetags.builtins.filters import render_markdown
|
|
from utilities.views import get_viewname
|
|
|
|
__all__ = (
|
|
'ActionsColumn',
|
|
'ArrayColumn',
|
|
'BooleanColumn',
|
|
'ChoiceFieldColumn',
|
|
'ChoicesColumn',
|
|
'ColorColumn',
|
|
'ColoredLabelColumn',
|
|
'ContentTypeColumn',
|
|
'ContentTypesColumn',
|
|
'CustomFieldColumn',
|
|
'CustomLinkColumn',
|
|
'DurationColumn',
|
|
'LinkedCountColumn',
|
|
'MarkdownColumn',
|
|
'ManyToManyColumn',
|
|
'MPTTColumn',
|
|
'TagColumn',
|
|
'TemplateColumn',
|
|
'ToggleColumn',
|
|
'UtilizationColumn',
|
|
)
|
|
|
|
|
|
#
|
|
# Django-tables2 overrides
|
|
#
|
|
|
|
@library.register
|
|
class DateColumn(tables.Column):
|
|
"""
|
|
Render a datetime.date in ISO 8601 format.
|
|
"""
|
|
def render(self, value):
|
|
if value:
|
|
return value.isoformat()
|
|
|
|
def value(self, value):
|
|
if value:
|
|
return value.isoformat()
|
|
|
|
@classmethod
|
|
def from_field(cls, field, **kwargs):
|
|
if isinstance(field, DateField):
|
|
return cls(**kwargs)
|
|
|
|
|
|
@library.register
|
|
class DateTimeColumn(tables.Column):
|
|
"""
|
|
Render a datetime.datetime in ISO 8601 format.
|
|
|
|
Args:
|
|
timespec: Granularity specification; passed through to datetime.isoformat()
|
|
"""
|
|
def __init__(self, *args, timespec='seconds', **kwargs):
|
|
self.timespec = timespec
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def render(self, value):
|
|
if value:
|
|
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
|
|
|
|
def value(self, value):
|
|
if value:
|
|
return value.isoformat()
|
|
|
|
@classmethod
|
|
def from_field(cls, field, **kwargs):
|
|
if isinstance(field, DateTimeField):
|
|
return cls(**kwargs)
|
|
|
|
|
|
class DurationColumn(tables.Column):
|
|
"""
|
|
Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m"
|
|
"""
|
|
def render(self, value):
|
|
ret = ''
|
|
if days := value // 1440:
|
|
ret += f'{days}d '
|
|
if hours := value % 1440 // 60:
|
|
ret += f'{hours}h '
|
|
if minutes := value % 60:
|
|
ret += f'{minutes}m'
|
|
return ret.strip()
|
|
|
|
def value(self, value):
|
|
return value
|
|
|
|
|
|
class ManyToManyColumn(tables.ManyToManyColumn):
|
|
"""
|
|
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
|
|
"""
|
|
def value(self, value):
|
|
items = [self.transform(item) for item in self.filter(value)]
|
|
return self.separator.join(items)
|
|
|
|
|
|
class TemplateColumn(tables.TemplateColumn):
|
|
"""
|
|
Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
|
|
is an empty string.
|
|
"""
|
|
PLACEHOLDER = mark_safe('—')
|
|
|
|
def __init__(self, export_raw=False, **kwargs):
|
|
"""
|
|
Args:
|
|
export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
|
|
False)
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self.export_raw = export_raw
|
|
|
|
def render(self, *args, **kwargs):
|
|
ret = super().render(*args, **kwargs)
|
|
if not ret.strip():
|
|
return self.PLACEHOLDER
|
|
return ret
|
|
|
|
def value(self, **kwargs):
|
|
if self.export_raw:
|
|
# Skip template rendering and export raw value
|
|
return kwargs.get('value')
|
|
|
|
ret = super().value(**kwargs)
|
|
if ret == self.PLACEHOLDER:
|
|
return ''
|
|
return ret
|
|
|
|
|
|
#
|
|
# Custom columns
|
|
#
|
|
|
|
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'] = {
|
|
'th': {
|
|
'class': 'w-1',
|
|
},
|
|
'td': {
|
|
'class': 'w-1',
|
|
},
|
|
'input': {
|
|
'class': 'form-check-input'
|
|
}
|
|
}
|
|
super().__init__(*args, default=default, visible=visible, **kwargs)
|
|
|
|
@property
|
|
def header(self):
|
|
title_text = _('Toggle all')
|
|
return mark_safe(f'<input type="checkbox" class="toggle form-check-input" title="{title_text}" />')
|
|
|
|
|
|
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)
|
|
|
|
|
|
@dataclass
|
|
class ActionsItem:
|
|
title: str
|
|
icon: str
|
|
permission: Optional[str] = None
|
|
css_class: Optional[str] = 'secondary'
|
|
|
|
|
|
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 actions: The ordered list of dropdown menu items to include
|
|
:param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
|
|
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
|
|
direct button link and icon (default: True)
|
|
"""
|
|
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
|
empty_values = ()
|
|
actions = {
|
|
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
|
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
|
|
'changelog': ActionsItem('Changelog', 'history'),
|
|
}
|
|
|
|
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.extra_buttons = extra_buttons
|
|
self.split_actions = split_actions
|
|
|
|
# Determine which actions to enable
|
|
self.actions = {
|
|
name: self.actions[name] for name in actions
|
|
}
|
|
|
|
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
|
|
if request := getattr(table, 'context', {}).get('request'):
|
|
return_url = request.GET.get('return_url', request.get_full_path())
|
|
url_appendix = f'?return_url={quote(return_url)}'
|
|
else:
|
|
url_appendix = ''
|
|
|
|
html = ''
|
|
|
|
# Compile actions menu
|
|
button = None
|
|
dropdown_class = 'secondary'
|
|
dropdown_links = []
|
|
user = getattr(request, 'user', AnonymousUser())
|
|
for idx, (action, attrs) in enumerate(self.actions.items()):
|
|
permission = get_permission_for_model(model, attrs.permission)
|
|
if attrs.permission is None or user.has_perm(permission):
|
|
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
|
|
|
# Render a separate button if a) only one action exists, or b) if split_actions is True
|
|
if len(self.actions) == 1 or (self.split_actions and idx == 0):
|
|
dropdown_class = attrs.css_class
|
|
button = (
|
|
f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
|
|
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
|
|
)
|
|
|
|
# Add dropdown menu items
|
|
else:
|
|
dropdown_links.append(
|
|
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
|
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
|
)
|
|
|
|
# Create the actions dropdown menu
|
|
toggle_text = _('Toggle Dropdown')
|
|
if button and dropdown_links:
|
|
html += (
|
|
f'<span class="btn-group dropdown">'
|
|
f' {button}'
|
|
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
|
f' <span class="visually-hidden">{toggle_text}</span></a>'
|
|
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
|
f'</span>'
|
|
)
|
|
elif button:
|
|
html += button
|
|
elif dropdown_links:
|
|
html += (
|
|
f'<span class="btn-group dropdown">'
|
|
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
|
|
f' <span class="visually-hidden">{toggle_text}</span></a>'
|
|
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
|
f'</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})
|
|
html = template.render(context) + html
|
|
|
|
return mark_safe(html)
|
|
|
|
|
|
class ChoiceFieldColumn(tables.Column):
|
|
"""
|
|
Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is
|
|
set by the instance's get_FOO_color() method, if defined.
|
|
"""
|
|
DEFAULT_BG_COLOR = 'secondary'
|
|
|
|
def render(self, record, bound_column, value):
|
|
if value in self.empty_values:
|
|
return self.default
|
|
|
|
# Determine the background color to use (try calling object.get_FOO_color())
|
|
try:
|
|
bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
|
|
except AttributeError:
|
|
bg_color = self.DEFAULT_BG_COLOR
|
|
|
|
return mark_safe(f'<span class="badge text-bg-{bg_color}">{value}</span>')
|
|
|
|
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 object_type_name(value, include_app=False)
|
|
|
|
def value(self, value):
|
|
if value is None:
|
|
return None
|
|
return object_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 object_type_name(obj, include_app=False)
|
|
|
|
def value(self, value):
|
|
return ','.join([
|
|
object_type_identifier(ot) for ot in self.filter(value)
|
|
])
|
|
|
|
|
|
class ColorColumn(tables.Column):
|
|
"""
|
|
Display an arbitrary color value, specified in RRGGBB format.
|
|
"""
|
|
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 related object as a colored label. The related object must have a `color` attribute (specifying
|
|
an RRGGBB value) and a `get_absolute_url()` method.
|
|
"""
|
|
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 %}
|
|
{% empty %}
|
|
<span class="text-muted">—</span>
|
|
{% endfor %}
|
|
"""
|
|
|
|
def __init__(self, url_name=None):
|
|
super().__init__(
|
|
orderable=False,
|
|
template_code=self.template_code,
|
|
extra_context={'url_name': url_name},
|
|
verbose_name=_('Tags'),
|
|
)
|
|
|
|
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
|
|
# We can't logically sort on FK values
|
|
if customfield.type in (
|
|
CustomFieldTypeChoices.TYPE_OBJECT,
|
|
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
|
):
|
|
kwargs['orderable'] = False
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def _linkify_item(item):
|
|
if hasattr(item, 'get_absolute_url'):
|
|
return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
|
|
return escape(item)
|
|
|
|
def render(self, value):
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
|
|
return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
|
|
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
|
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
|
|
return self.customfield.get_choice_label(value)
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
|
return ', '.join(self.customfield.get_choice_label(v) for v in value)
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
|
return mark_safe(', '.join(
|
|
self._linkify_item(obj) for obj in self.customfield.deserialize(value)
|
|
))
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
|
return render_markdown(value)
|
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
|
|
return parse_date(value).isoformat()
|
|
if value is not None:
|
|
obj = self.customfield.deserialize(value)
|
|
return mark_safe(self._linkify_item(obj))
|
|
return self.default
|
|
|
|
def value(self, value):
|
|
if isinstance(value, list):
|
|
return ','.join(str(v) for v in self.customfield.deserialize(value))
|
|
if value is not None:
|
|
return self.customfield.deserialize(value)
|
|
return self.default
|
|
|
|
|
|
class CustomLinkColumn(tables.Column):
|
|
"""
|
|
Render a custom link as a table column.
|
|
"""
|
|
def __init__(self, customlink, *args, **kwargs):
|
|
self.customlink = customlink
|
|
kwargs.setdefault('accessor', Accessor('pk'))
|
|
kwargs.setdefault('orderable', False)
|
|
kwargs.setdefault('verbose_name', customlink.name)
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _render_customlink(self, record, table):
|
|
context = {
|
|
'object': record,
|
|
'debug': settings.DEBUG,
|
|
}
|
|
if request := getattr(table, 'context', {}).get('request'):
|
|
# If the request is available, include it as context
|
|
context.update({
|
|
'request': request,
|
|
**auth(request),
|
|
})
|
|
|
|
return self.customlink.render(context)
|
|
|
|
def render(self, record, table, **kwargs):
|
|
try:
|
|
if rendered := self._render_customlink(record, table):
|
|
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
|
|
except Exception as e:
|
|
error_text = _('Error')
|
|
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
|
|
return ''
|
|
|
|
def value(self, record, table, **kwargs):
|
|
try:
|
|
if rendered := self._render_customlink(record, table):
|
|
return rendered['link']
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
class MPTTColumn(tables.TemplateColumn):
|
|
"""
|
|
Display a nested hierarchy for MPTT-enabled models.
|
|
"""
|
|
template_code = """
|
|
{% load helpers %}
|
|
{% if not table.order_by %}
|
|
{% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
|
|
{% endif %}
|
|
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(
|
|
template_code=self.template_code,
|
|
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 = """
|
|
{% if value %}
|
|
{{ value|markdown }}
|
|
{% else %}
|
|
—
|
|
{% endif %}
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(
|
|
template_code=self.template_code,
|
|
**kwargs,
|
|
)
|
|
|
|
def value(self, value):
|
|
return value
|
|
|
|
|
|
class ArrayColumn(tables.Column):
|
|
"""
|
|
List array items as a comma-separated list.
|
|
"""
|
|
def __init__(self, *args, max_items=None, func=str, **kwargs):
|
|
self.max_items = max_items
|
|
self.func = func
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def render(self, value):
|
|
omitted_count = 0
|
|
|
|
# Limit the returned items to the specified maximum number (if any)
|
|
if self.max_items:
|
|
omitted_count = len(value) - self.max_items
|
|
value = value[:self.max_items - 1]
|
|
|
|
# Apply custom processing function (if any) per item
|
|
if self.func:
|
|
value = [self.func(v) for v in value]
|
|
|
|
# Annotate omitted items (if applicable)
|
|
if omitted_count > 0:
|
|
value.append(f'({omitted_count} more)')
|
|
|
|
return ', '.join(value)
|
|
|
|
|
|
class ChoicesColumn(tables.Column):
|
|
"""
|
|
Display the human-friendly labels of a set of choices.
|
|
"""
|
|
def __init__(self, *args, max_items=None, **kwargs):
|
|
self.max_items = max_items
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def render(self, value):
|
|
omitted_count = 0
|
|
value = [v[1] for v in value]
|
|
|
|
# Limit the returned items to the specified maximum number (if any)
|
|
if self.max_items:
|
|
omitted_count = len(value) - self.max_items
|
|
value = value[:self.max_items - 1]
|
|
|
|
# Annotate omitted items (if applicable)
|
|
if omitted_count > 0:
|
|
value.append(f'({omitted_count} more)')
|
|
|
|
return ', '.join(value)
|