| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804 |
- import zoneinfo
- from dataclasses import dataclass
- from datetime import timedelta
- 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 Case, DateField, DateTimeField, F, IntegerField, Value, When
- from django.db.models.fields.json import KeyTextTransform
- from django.template import Context, Template
- from django.urls import reverse
- from django.utils.dateparse import parse_date
- from django.utils.html import escape, format_html
- from django.utils.safestring import mark_safe
- from django.utils.text import format_lazy
- 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.request import get_safe_request_context
- from utilities.string import humanize_duration
- from utilities.templatetags.builtins.filters import render_markdown
- from utilities.validators import url_scheme_is_allowed
- from utilities.views import get_action_url
- __all__ = (
- 'ActionsColumn',
- 'ArrayColumn',
- 'BooleanColumn',
- 'ChoiceFieldColumn',
- 'ChoicesColumn',
- 'ColorColumn',
- 'ColoredLabelColumn',
- 'ContentTypeColumn',
- 'ContentTypesColumn',
- 'CustomFieldColumn',
- 'CustomLinkColumn',
- 'DictColumn',
- 'DistanceColumn',
- 'DurationColumn',
- 'LinkedCountColumn',
- 'MPTTColumn',
- 'ManyToManyColumn',
- 'MarkdownColumn',
- 'TagColumn',
- 'TemplateColumn',
- 'ToggleColumn',
- 'TreeColumn',
- '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()
- return None
- def value(self, value):
- if value:
- return value.isoformat()
- return None
- @classmethod
- def from_field(cls, field, **kwargs):
- if isinstance(field, DateField):
- return cls(**kwargs)
- return None
- @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:
- current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
- value = value.astimezone(current_tz)
- return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
- return None
- def value(self, value):
- if value:
- return value.isoformat()
- return None
- @classmethod
- def from_field(cls, field, **kwargs):
- if isinstance(field, DateTimeField):
- return cls(**kwargs)
- return None
- class DurationColumn(tables.Column):
- """
- Express a duration of time in a human-friendly format. Accepts either a timedelta or a count of
- minutes. Example: 437 minutes becomes "7h 17m"
- """
- def render(self, value):
- if not isinstance(value, timedelta):
- value = timedelta(minutes=value)
- return humanize_duration(value)
- 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',
- 'aria-label': _('Select all'),
- },
- 'td': {
- 'class': 'w-1',
- },
- 'input': {
- 'class': 'form-check-input',
- 'aria-label': lambda record, value: format_lazy(_('Select {object}'), object=record),
- }
- }
- super().__init__(*args, default=default, visible=visible, **kwargs)
- @property
- def header(self):
- title_text = _('Toggle all')
- return format_html(
- '<input type="checkbox" class="toggle form-check-input" title="{}" aria-label="{}" />',
- title_text, title_text,
- )
- class BooleanColumn(tables.Column):
- """
- Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
- character.
- """
- TRUE_MARK = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold"></i></span>')
- FALSE_MARK = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>')
- EMPTY_MARK = mark_safe('<span class="text-muted">—</span>') # Placeholder
- def __init__(self, *args, true_mark=TRUE_MARK, false_mark=FALSE_MARK, **kwargs):
- self.true_mark = true_mark
- self.false_mark = false_mark
- super().__init__(*args, **kwargs)
- def render(self, value):
- if value is None:
- return self.EMPTY_MARK
- if value and self.true_mark:
- return self.true_mark
- if not value and self.false_mark:
- return self.false_mark
- return self.EMPTY_MARK
- def value(self, value):
- return str(value)
- @dataclass
- class ActionsItem:
- title: str
- icon: str
- permission: str | None = None
- css_class: str | None = '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 = {
- 'th': {
- 'aria-label': _('Actions'),
- },
- 'td': {
- 'class': 'text-end text-nowrap noprint p-1'
- }
- }
- 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):
- model = table.Meta.model
- # Skip if no actions or extra buttons are defined
- if not (self.actions or self.extra_buttons):
- return ''
- # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs)
- if not isinstance(record, model) or not getattr(record, 'pk', None):
- return ''
- 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 = get_action_url(model, action=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'aria-label="{attrs.title}">'
- 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" '
- f'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, or can be overridden by a "color" callable.
- """
- DEFAULT_BG_COLOR = 'secondary'
- def __init__(self, *args, color=None, **kwargs):
- super().__init__(*args, **kwargs)
- self.color = color
- def render(self, record, bound_column, value):
- if value in self.empty_values:
- return self.default
- # Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color())
- if self.color:
- bg_color = self.color(record)
- else:
- 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}">{escape(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)
- def order(self, queryset, is_descending):
- # Order by the underlying JSON value, honoring the custom field's null placement preference.
- # A missing key or a JSON null value is extracted as SQL NULL via the ->> (text) operator,
- # whereas the -> (JSONB) operator used for value ordering treats JSON null as a sortable value.
- # We therefore annotate an explicit rank to control null placement independently of JSONB sorting.
- name = self.customfield.name
- text_value = f'_cf_{name}_text'
- null_rank = f'_cf_{name}_nullrank'
- null_sort, value_sort = (0, 1) if self.customfield.nulls_first else (1, 0)
- queryset = queryset.annotate(**{
- text_value: KeyTextTransform(name, 'custom_field_data'),
- }).annotate(**{
- null_rank: Case(
- When(**{f'{text_value}__isnull': True}, then=Value(null_sort)),
- default=Value(value_sort),
- output_field=IntegerField(),
- ),
- })
- value = F(f'custom_field_data__{name}')
- ordering = (null_rank, value.desc() if is_descending else value.asc())
- return queryset.order_by(*ordering), True
- @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:
- # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against
- # dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless
- # (relative) value is considered safe.
- if url_scheme_is_allowed(value):
- return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
- return escape(value)
- if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
- if value is None:
- return self.default
- label = self.customfield.get_choice_label(value)
- color = self.customfield.get_choice_color(value)
- if color:
- return mark_safe(
- f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
- )
- return label
- if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
- if not value:
- return ''
- has_color = False
- parts = []
- for v in value:
- label = self.customfield.get_choice_label(v)
- color = self.customfield.get_choice_color(v)
- if color:
- has_color = True
- parts.append((label, color))
- if has_color:
- badges = []
- for label, color in parts:
- badges.append(
- f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
- )
- return mark_safe(' '.join(badges))
- return ', '.join(label for label, _ in parts)
- 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 a sanitized subset of it as context
- context.update({
- 'request': get_safe_request_context(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 TreeColumn(tables.TemplateColumn):
- """
- Display a nested hierarchy for tree-enabled models (Region, Location, etc.).
- """
- 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
- # Deprecated alias for plugin compatibility; use TreeColumn going forward.
- # TODO: Remove this in NetBox v5.0
- MPTTColumn = TreeColumn
- 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)
- class DistanceColumn(TemplateColumn):
- """
- Distance with template code for formatting
- """
- template_code = """
- {% load helpers %}
- {% display_distance record.distance record.distance_unit record.abs_distance %}
- """
- def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
- super().__init__(template_code=template_code, order_by=order_by, **kwargs)
- class DictColumn(tables.Column):
- """
- Render a dictionary of data in a simple key: value format, one pair per line.
- """
- def render(self, value):
- output = '<br />'.join([
- f'{escape(k)}: {escape(v)}' for k, v in value.items()
- ])
- return mark_safe(output)
|