Coverage for netbox/tables/columns.py: 35%
402 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import zoneinfo
2from dataclasses import dataclass
3from urllib.parse import quote
5import django_tables2 as tables
6from django.conf import settings
7from django.contrib.auth.context_processors import auth
8from django.contrib.auth.models import AnonymousUser
9from django.db.models import Case, DateField, DateTimeField, IntegerField, Q, Value, When
10from django.db.models.fields.json import KeyTextTransform
11from django.template import Context, Template
12from django.urls import reverse
13from django.utils.dateparse import parse_date
14from django.utils.html import escape, format_html
15from django.utils.safestring import mark_safe
16from django.utils.text import format_lazy
17from django.utils.translation import gettext_lazy as _
18from django_tables2.columns import library
19from django_tables2.utils import Accessor
21from extras.choices import CustomFieldTypeChoices
22from utilities.object_types import object_type_identifier, object_type_name
23from utilities.permissions import get_permission_for_model
24from utilities.request import get_safe_request_context
25from utilities.templatetags.builtins.filters import render_markdown
26from utilities.validators import url_scheme_is_allowed
27from utilities.views import get_action_url
29__all__ = (
30 'ActionsColumn',
31 'ArrayColumn',
32 'BooleanColumn',
33 'ChoiceFieldColumn',
34 'ChoicesColumn',
35 'ColorColumn',
36 'ColoredLabelColumn',
37 'ContentTypeColumn',
38 'ContentTypesColumn',
39 'CustomFieldColumn',
40 'CustomLinkColumn',
41 'DictColumn',
42 'DistanceColumn',
43 'DurationColumn',
44 'LinkedCountColumn',
45 'MPTTColumn',
46 'ManyToManyColumn',
47 'MarkdownColumn',
48 'TagColumn',
49 'TemplateColumn',
50 'ToggleColumn',
51 'TreeColumn',
52 'UtilizationColumn',
53)
56#
57# Django-tables2 overrides
58#
60@library.register
61class DateColumn(tables.Column):
62 """
63 Render a datetime.date in ISO 8601 format.
64 """
65 def render(self, value):
66 if value:
67 return value.isoformat()
68 return None
70 def value(self, value):
71 if value:
72 return value.isoformat()
73 return None
75 @classmethod
76 def from_field(cls, field, **kwargs):
77 if isinstance(field, DateField):
78 return cls(**kwargs)
79 return None
82@library.register
83class DateTimeColumn(tables.Column):
84 """
85 Render a datetime.datetime in ISO 8601 format.
87 Args:
88 timespec: Granularity specification; passed through to datetime.isoformat()
89 """
90 def __init__(self, *args, timespec='seconds', **kwargs):
91 self.timespec = timespec
92 super().__init__(*args, **kwargs)
94 def render(self, value):
95 if value:
96 current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
97 value = value.astimezone(current_tz)
98 return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
99 return None
101 def value(self, value):
102 if value:
103 return value.isoformat()
104 return None
106 @classmethod
107 def from_field(cls, field, **kwargs):
108 if isinstance(field, DateTimeField):
109 return cls(**kwargs)
110 return None
113class DurationColumn(tables.Column):
114 """
115 Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m"
116 """
117 def render(self, value):
118 ret = ''
119 if days := value // 1440:
120 ret += f'{days}d '
121 if hours := value % 1440 // 60:
122 ret += f'{hours}h '
123 if minutes := value % 60:
124 ret += f'{minutes}m'
125 return ret.strip()
127 def value(self, value):
128 return value
131class ManyToManyColumn(tables.ManyToManyColumn):
132 """
133 Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
134 """
135 def value(self, value):
136 items = [self.transform(item) for item in self.filter(value)]
137 return self.separator.join(items)
140class TemplateColumn(tables.TemplateColumn):
141 """
142 Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
143 is an empty string.
144 """
145 PLACEHOLDER = mark_safe('—')
147 def __init__(self, export_raw=False, **kwargs):
148 """
149 Args:
150 export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
151 False)
152 """
153 super().__init__(**kwargs)
154 self.export_raw = export_raw
156 def render(self, *args, **kwargs):
157 ret = super().render(*args, **kwargs)
158 if not ret.strip():
159 return self.PLACEHOLDER
160 return ret
162 def value(self, **kwargs):
163 if self.export_raw:
164 # Skip template rendering and export raw value
165 return kwargs.get('value')
167 ret = super().value(**kwargs)
168 if ret == self.PLACEHOLDER:
169 return ''
170 return ret
173#
174# Custom columns
175#
177class ToggleColumn(tables.CheckBoxColumn):
178 """
179 Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
180 """
181 def __init__(self, *args, **kwargs):
182 default = kwargs.pop('default', '')
183 visible = kwargs.pop('visible', False)
184 if 'attrs' not in kwargs: 184 ↛ 198line 184 didn't jump to line 198 because the condition on line 184 was always true
185 kwargs['attrs'] = {
186 'th': {
187 'class': 'w-1',
188 'aria-label': _('Select all'),
189 },
190 'td': {
191 'class': 'w-1',
192 },
193 'input': {
194 'class': 'form-check-input',
195 'aria-label': lambda record, value: format_lazy(_('Select {object}'), object=record),
196 }
197 }
198 super().__init__(*args, default=default, visible=visible, **kwargs)
200 @property
201 def header(self):
202 title_text = _('Toggle all')
203 return format_html(
204 '<input type="checkbox" class="toggle form-check-input" title="{}" aria-label="{}" />',
205 title_text, title_text,
206 )
209class BooleanColumn(tables.Column):
210 """
211 Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
212 character.
213 """
214 TRUE_MARK = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold"></i></span>')
215 FALSE_MARK = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>')
216 EMPTY_MARK = mark_safe('<span class="text-muted">—</span>') # Placeholder
218 def __init__(self, *args, true_mark=TRUE_MARK, false_mark=FALSE_MARK, **kwargs):
219 self.true_mark = true_mark
220 self.false_mark = false_mark
221 super().__init__(*args, **kwargs)
223 def render(self, value):
224 if value is None:
225 return self.EMPTY_MARK
226 if value and self.true_mark:
227 return self.true_mark
228 if not value and self.false_mark:
229 return self.false_mark
230 return self.EMPTY_MARK
232 def value(self, value):
233 return str(value)
236@dataclass
237class ActionsItem:
238 title: str
239 icon: str
240 permission: str | None = None
241 css_class: str | None = 'secondary'
244class ActionsColumn(tables.Column):
245 """
246 A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include
247 additional buttons rendered from a template string.
249 :param actions: The ordered list of dropdown menu items to include
250 :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
251 :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
252 direct button link and icon (default: True)
253 """
254 attrs = {
255 'th': {
256 'aria-label': _('Actions'),
257 },
258 'td': {
259 'class': 'text-end text-nowrap noprint p-1'
260 }
261 }
262 empty_values = ()
263 actions = {
264 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
265 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
266 'changelog': ActionsItem('Changelog', 'history'),
267 }
269 def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
270 super().__init__(*args, **kwargs)
272 self.extra_buttons = extra_buttons
273 self.split_actions = split_actions
275 # Determine which actions to enable
276 self.actions = {
277 name: self.actions[name] for name in actions
278 }
280 def header(self):
281 return ''
283 def render(self, record, table, **kwargs):
284 model = table.Meta.model
286 # Skip if no actions or extra buttons are defined
287 if not (self.actions or self.extra_buttons):
288 return ''
289 # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs)
290 if not isinstance(record, model) or not getattr(record, 'pk', None):
291 return ''
293 if request := getattr(table, 'context', {}).get('request'):
294 return_url = request.GET.get('return_url', request.get_full_path())
295 url_appendix = f'?return_url={quote(return_url)}'
296 else:
297 url_appendix = ''
299 html = ''
301 # Compile actions menu
302 button = None
303 dropdown_class = 'secondary'
304 dropdown_links = []
305 user = getattr(request, 'user', AnonymousUser())
306 for idx, (action, attrs) in enumerate(self.actions.items()):
307 permission = get_permission_for_model(model, attrs.permission)
308 if attrs.permission is None or user.has_perm(permission):
309 url = get_action_url(model, action=action, kwargs={'pk': record.pk})
311 # Render a separate button if a) only one action exists, or b) if split_actions is True
312 if len(self.actions) == 1 or (self.split_actions and idx == 0):
313 dropdown_class = attrs.css_class
314 button = (
315 f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button" '
316 f'aria-label="{attrs.title}">'
317 f'<i class="mdi mdi-{attrs.icon}"></i></a>'
318 )
320 # Add dropdown menu items
321 else:
322 dropdown_links.append(
323 f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
324 f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
325 )
327 # Create the actions dropdown menu
328 toggle_text = _('Toggle Dropdown')
329 if button and dropdown_links:
330 html += (
331 f'<span class="btn-group dropdown">'
332 f' {button}'
333 f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" '
334 f'style="padding-left: 2px">'
335 f' <span class="visually-hidden">{toggle_text}</span></a>'
336 f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
337 f'</span>'
338 )
339 elif button:
340 html += button
341 elif dropdown_links:
342 html += (
343 f'<span class="btn-group dropdown">'
344 f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
345 f' <span class="visually-hidden">{toggle_text}</span></a>'
346 f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
347 f'</span>'
348 )
350 # Render any extra buttons from template code
351 if self.extra_buttons:
352 template = Template(self.extra_buttons)
353 context = getattr(table, "context", Context())
354 context.update({'record': record})
355 html = template.render(context) + html
357 return mark_safe(html)
360class ChoiceFieldColumn(tables.Column):
361 """
362 Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is
363 set by the instance's get_FOO_color() method, if defined, or can be overridden by a "color" callable.
364 """
365 DEFAULT_BG_COLOR = 'secondary'
367 def __init__(self, *args, color=None, **kwargs):
368 super().__init__(*args, **kwargs)
369 self.color = color
371 def render(self, record, bound_column, value):
372 if value in self.empty_values:
373 return self.default
375 # Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color())
376 if self.color:
377 bg_color = self.color(record)
378 else:
379 try:
380 bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
381 except AttributeError:
382 bg_color = self.DEFAULT_BG_COLOR
384 return mark_safe(f'<span class="badge text-bg-{bg_color}">{value}</span>')
386 def value(self, value):
387 return value
390class ContentTypeColumn(tables.Column):
391 """
392 Display a ContentType instance.
393 """
394 def render(self, value):
395 if value is None:
396 return None
397 return object_type_name(value, include_app=False)
399 def value(self, value):
400 if value is None:
401 return None
402 return object_type_identifier(value)
405class ContentTypesColumn(tables.ManyToManyColumn):
406 """
407 Display a list of ContentType instances.
408 """
409 def __init__(self, separator=None, *args, **kwargs):
410 # Use a line break as the default separator
411 if separator is None: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true
412 separator = mark_safe('<br />')
413 super().__init__(separator=separator, *args, **kwargs)
415 def transform(self, obj):
416 return object_type_name(obj, include_app=False)
418 def value(self, value):
419 return ','.join([
420 object_type_identifier(ot) for ot in self.filter(value)
421 ])
424class ColorColumn(tables.Column):
425 """
426 Display an arbitrary color value, specified in RRGGBB format.
427 """
428 def render(self, value):
429 return mark_safe(
430 f'<span class="color-label" style="background-color: #{value}"> </span>'
431 )
433 def value(self, value):
434 return f'#{value}'
437class ColoredLabelColumn(tables.TemplateColumn):
438 """
439 Render a related object as a colored label. The related object must have a `color` attribute (specifying
440 an RRGGBB value) and a `get_absolute_url()` method.
441 """
442 template_code = """
443{% load helpers %}
444 {% if value %}
445 <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
446 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
447 </span>
448{% else %}
449 —
450{% endif %}
451"""
453 def __init__(self, *args, **kwargs):
454 super().__init__(template_code=self.template_code, *args, **kwargs)
456 def value(self, value):
457 return str(value)
460class LinkedCountColumn(tables.Column):
461 """
462 Render a count of related objects linked to a filtered URL.
464 :param viewname: The view name to use for URL resolution
465 :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
466 :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
467 """
468 def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
469 self.viewname = viewname
470 self.view_kwargs = view_kwargs or {}
471 self.url_params = url_params
472 super().__init__(*args, default=default, **kwargs)
474 def render(self, record, value):
475 if value:
476 url = reverse(self.viewname, kwargs=self.view_kwargs)
477 if self.url_params:
478 url += '?' + '&'.join([
479 f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
480 for k, v in self.url_params.items()
481 ])
482 return mark_safe(f'<a href="{url}">{escape(value)}</a>')
483 return value
485 def value(self, value):
486 return value
489class TagColumn(tables.TemplateColumn):
490 """
491 Display a list of Tags assigned to the object.
492 """
493 template_code = """
494 {% load helpers %}
495 {% for tag in value.all %}
496 {% tag tag url_name %}
497 {% empty %}
498 <span class="text-muted">—</span>
499 {% endfor %}
500 """
502 def __init__(self, url_name=None):
503 super().__init__(
504 orderable=False,
505 template_code=self.template_code,
506 extra_context={'url_name': url_name},
507 verbose_name=_('Tags'),
508 )
510 def value(self, value):
511 return ",".join([tag.name for tag in value.all()])
514class CustomFieldColumn(tables.Column):
515 """
516 Display custom fields in the appropriate format.
517 """
518 def __init__(self, customfield, *args, **kwargs):
519 self.customfield = customfield
520 kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
521 if 'verbose_name' not in kwargs:
522 kwargs['verbose_name'] = customfield.label or customfield.name
523 # We can't logically sort on FK values
524 if customfield.type in (
525 CustomFieldTypeChoices.TYPE_OBJECT,
526 CustomFieldTypeChoices.TYPE_MULTIOBJECT
527 ):
528 kwargs['orderable'] = False
529 else:
530 kwargs.setdefault('order_by', (
531 self.unset_alias,
532 f'custom_field_data__{customfield.name}',
533 ))
535 super().__init__(*args, **kwargs)
537 @property
538 def unset_alias(self):
539 """
540 Return the name of the annotation which groups together the objects holding no value for
541 this field (see get_ordering_annotation()).
543 The annotation is named for the custom field so that ordering by two custom field columns
544 cannot produce a duplicate alias. Field names are validated to contain only alphanumerics
545 and underscores, so the alias is always a legal identifier.
546 """
547 return f'_cf_{self.customfield.name}_unset'
549 def get_ordering_annotation(self):
550 """
551 Return the annotation by which objects holding no value for this field are sorted together,
552 as the leading sort key for the column. (BaseTable applies it to the queryset when ordering
553 by this column.)
555 An object can lack a value either by storing a JSON null or by carrying no key for the
556 field at all -- the latter being the normal state for objects which predate it, as data is
557 no longer provisioned onto existing objects (see CustomField.populate_initial_data()).
558 Postgres sorts those two apart: a JSON null is the lowest jsonb value, whereas a missing
559 key yields SQL NULL and sorts last, so the "empty" rows would otherwise land at both ends
560 of the same column. This key (the `empty` lookup covers both states) groups them at one
561 end, matching how SQL NULLs are ordered for an ordinary column: last when ascending, first
562 when descending. The column's second sort key then orders by the raw value, so that numeric
563 and date fields still sort by type rather than lexically.
564 """
565 return {
566 self.unset_alias: Q(**{f'custom_field_data__{self.customfield.name}__empty': True})
567 }
569 def order(self, queryset, is_descending):
570 """
571 Override get_ordering_annotation()'s default (SQL-standard, direction-coupled) null
572 placement to honor the custom field's nulls_first attribute instead: the empty group's
573 position is fixed by admin preference, independent of ascending/descending. Returning
574 (queryset, True) here signals django-tables2 to use this ordering as-is, bypassing the
575 generic annotation set up by get_ordering_annotation() (which still runs, but its result
576 goes unused for this column since only its alias name -- referenced by unset_alias --
577 needs to exist, not the SQL-standard placement it would otherwise apply).
579 A missing key or a JSON null value is extracted as SQL NULL via the ->> (text) operator,
580 whereas the -> (JSONB) operator used for value ordering treats JSON null as a sortable
581 value. We therefore annotate an explicit rank to control null placement independently of
582 JSONB sorting.
584 Ordering is expressed as plain string keys (not F()-based OrderBy expressions): NetBox's
585 BaseTable._apply_ordering_tie_breaker() inspects self.data.data.query.order_by afterward
586 and wraps each entry in django-tables2's own (string-only) OrderBy helper, which raises
587 TypeError on a raw expression object.
589 Trade-off: returning (queryset, True) here is django-tables2's signal that this column
590 has fully handled ordering itself, which takes priority over -- and discards -- any other
591 columns' sort keys requested in the same multi-column sort (see TableQuerysetData.order_by()
592 in django_tables2/data.py: the loop applies whichever column's order() last returns
593 modified=True and returns immediately, never combining it with sibling columns'
594 contributions). A CustomFieldColumn can therefore not currently be composed with other
595 columns in a single sort; it is always the sole and final sort key when included. Preserving
596 nulls_first (an existing, widely-integrated per-field admin setting) was judged to matter
597 more than gaining composability for this specific column, since django-tables2's per-key
598 ascending/descending toggle is applied uniformly across an entire order_by tuple and cannot
599 keep one key's effective placement constant while another flips -- so nulls_first and
600 multi-column composition cannot both be expressed through the generic annotation mechanism
601 for the same column.
602 """
603 name = self.customfield.name
604 text_value = f'_cf_{name}_text'
605 null_rank = f'_cf_{name}_nullrank'
606 null_sort, value_sort = (0, 1) if self.customfield.nulls_first else (1, 0)
607 queryset = queryset.annotate(**{
608 text_value: KeyTextTransform(name, 'custom_field_data'),
609 }).annotate(**{
610 null_rank: Case(
611 When(**{f'{text_value}__isnull': True}, then=Value(null_sort)),
612 default=Value(value_sort),
613 output_field=IntegerField(),
614 ),
615 })
616 value_field = f'custom_field_data__{name}'
617 ordering = (null_rank, f'-{value_field}' if is_descending else value_field)
618 return queryset.order_by(*ordering), True
620 @staticmethod
621 def _linkify_item(item):
622 if hasattr(item, 'get_absolute_url'):
623 return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
624 return escape(item)
626 def render(self, value):
627 if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
628 return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
629 if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
630 return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
631 if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
632 # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against
633 # dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless
634 # (relative) value is considered safe.
635 if url_scheme_is_allowed(value):
636 return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
637 return escape(value)
638 if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
639 if value is None:
640 return self.default
641 label = self.customfield.get_choice_label(value)
642 color = self.customfield.get_choice_color(value)
643 if color:
644 return mark_safe(
645 f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
646 )
647 return label
648 if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
649 if not value:
650 return ''
652 has_color = False
653 parts = []
655 for v in value:
656 label = self.customfield.get_choice_label(v)
657 color = self.customfield.get_choice_color(v)
658 if color:
659 has_color = True
660 parts.append((label, color))
661 if has_color:
662 badges = []
663 for label, color in parts:
664 badges.append(
665 f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
666 )
667 return mark_safe(' '.join(badges))
668 return ', '.join(label for label, _ in parts)
670 if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
671 return mark_safe(', '.join(
672 self._linkify_item(obj) for obj in self.customfield.deserialize(value)
673 ))
674 if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
675 return render_markdown(value)
676 if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
677 return parse_date(value).isoformat()
678 if value is not None:
679 obj = self.customfield.deserialize(value)
680 return mark_safe(self._linkify_item(obj))
681 return self.default
683 def value(self, value):
684 if isinstance(value, list):
685 return ','.join(str(v) for v in self.customfield.deserialize(value))
686 if value is not None:
687 return self.customfield.deserialize(value)
688 return self.default
691class CustomLinkColumn(tables.Column):
692 """
693 Render a custom link as a table column.
694 """
695 def __init__(self, customlink, *args, **kwargs):
696 self.customlink = customlink
697 kwargs.setdefault('accessor', Accessor('pk'))
698 kwargs.setdefault('orderable', False)
699 kwargs.setdefault('verbose_name', customlink.name)
701 super().__init__(*args, **kwargs)
703 def _render_customlink(self, record, table):
704 context = {
705 'object': record,
706 'debug': settings.DEBUG,
707 }
708 if request := getattr(table, 'context', {}).get('request'):
709 # If the request is available, include a sanitized subset of it as context
710 context.update({
711 'request': get_safe_request_context(request),
712 **auth(request),
713 })
715 return self.customlink.render(context)
717 def render(self, record, table, **kwargs):
718 try:
719 if rendered := self._render_customlink(record, table):
720 return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
721 except Exception as e:
722 error_text = _('Error')
723 return format_html(
724 '<span class="text-danger" title="{}"><i class="mdi mdi-alert"></i> {}</span>', e, error_text
725 )
726 return ''
728 def value(self, record, table, **kwargs):
729 try:
730 if rendered := self._render_customlink(record, table):
731 return rendered['link']
732 except Exception:
733 pass
734 return None
737class TreeColumn(tables.TemplateColumn):
738 """
739 Display a nested hierarchy for tree-enabled models (Region, Location, etc.).
740 """
741 template_code = """
742 {% load helpers %}
743 {% if not table.order_by %}
744 {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
745 {% endif %}
746 <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
747 """
749 def __init__(self, *args, **kwargs):
750 super().__init__(
751 template_code=self.template_code,
752 attrs={'td': {'class': 'text-nowrap'}},
753 *args,
754 **kwargs
755 )
757 def value(self, value):
758 return value
761# Deprecated alias for plugin compatibility; use TreeColumn going forward.
762# TODO: Remove this in NetBox v5.0
763MPTTColumn = TreeColumn
766class UtilizationColumn(tables.TemplateColumn):
767 """
768 Display a colored utilization bar graph.
769 """
770 template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
772 def __init__(self, *args, **kwargs):
773 super().__init__(template_code=self.template_code, *args, **kwargs)
775 def value(self, value):
776 return f'{value}%'
779class MarkdownColumn(tables.TemplateColumn):
780 """
781 Render a Markdown string.
782 """
783 template_code = """
784 {% if value %}
785 {{ value|markdown }}
786 {% else %}
787 —
788 {% endif %}
789 """
791 def __init__(self, **kwargs):
792 super().__init__(
793 template_code=self.template_code,
794 **kwargs,
795 )
797 def value(self, value):
798 return value
801class ArrayColumn(tables.Column):
802 """
803 List array items as a comma-separated list.
804 """
805 def __init__(self, *args, max_items=None, func=str, **kwargs):
806 self.max_items = max_items
807 self.func = func
808 super().__init__(*args, **kwargs)
810 def render(self, value):
811 omitted_count = 0
813 # Limit the returned items to the specified maximum number (if any)
814 if self.max_items:
815 omitted_count = len(value) - self.max_items
816 value = value[:self.max_items - 1]
818 # Apply custom processing function (if any) per item
819 if self.func:
820 value = [self.func(v) for v in value]
822 # Annotate omitted items (if applicable)
823 if omitted_count > 0:
824 value.append(f'({omitted_count} more)')
826 return ', '.join(value)
829class ChoicesColumn(tables.Column):
830 """
831 Display the human-friendly labels of a set of choices.
832 """
833 def __init__(self, *args, max_items=None, **kwargs):
834 self.max_items = max_items
835 super().__init__(*args, **kwargs)
837 def render(self, value):
838 omitted_count = 0
839 value = [v[1] for v in value]
841 # Limit the returned items to the specified maximum number (if any)
842 if self.max_items:
843 omitted_count = len(value) - self.max_items
844 value = value[:self.max_items - 1]
846 # Annotate omitted items (if applicable)
847 if omitted_count > 0:
848 value.append(f'({omitted_count} more)')
850 return ', '.join(value)
853class DistanceColumn(TemplateColumn):
854 """
855 Distance with template code for formatting
856 """
857 template_code = """
858 {% load helpers %}
859 {% display_distance record.distance record.distance_unit record.abs_distance %}
860 """
862 def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
863 super().__init__(template_code=template_code, order_by=order_by, **kwargs)
866class DictColumn(tables.Column):
867 """
868 Render a dictionary of data in a simple key: value format, one pair per line.
869 """
870 def render(self, value):
871 output = '<br />'.join([
872 f'{escape(k)}: {escape(v)}' for k, v in value.items()
873 ])
874 return mark_safe(output)