columns.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804
  1. import zoneinfo
  2. from dataclasses import dataclass
  3. from datetime import timedelta
  4. from urllib.parse import quote
  5. import django_tables2 as tables
  6. from django.conf import settings
  7. from django.contrib.auth.context_processors import auth
  8. from django.contrib.auth.models import AnonymousUser
  9. from django.db.models import Case, DateField, DateTimeField, F, IntegerField, Value, When
  10. from django.db.models.fields.json import KeyTextTransform
  11. from django.template import Context, Template
  12. from django.urls import reverse
  13. from django.utils.dateparse import parse_date
  14. from django.utils.html import escape, format_html
  15. from django.utils.safestring import mark_safe
  16. from django.utils.text import format_lazy
  17. from django.utils.translation import gettext_lazy as _
  18. from django_tables2.columns import library
  19. from django_tables2.utils import Accessor
  20. from extras.choices import CustomFieldTypeChoices
  21. from utilities.object_types import object_type_identifier, object_type_name
  22. from utilities.permissions import get_permission_for_model
  23. from utilities.request import get_safe_request_context
  24. from utilities.string import humanize_duration
  25. from utilities.templatetags.builtins.filters import render_markdown
  26. from utilities.validators import url_scheme_is_allowed
  27. from utilities.views import get_action_url
  28. __all__ = (
  29. 'ActionsColumn',
  30. 'ArrayColumn',
  31. 'BooleanColumn',
  32. 'ChoiceFieldColumn',
  33. 'ChoicesColumn',
  34. 'ColorColumn',
  35. 'ColoredLabelColumn',
  36. 'ContentTypeColumn',
  37. 'ContentTypesColumn',
  38. 'CustomFieldColumn',
  39. 'CustomLinkColumn',
  40. 'DictColumn',
  41. 'DistanceColumn',
  42. 'DurationColumn',
  43. 'LinkedCountColumn',
  44. 'MPTTColumn',
  45. 'ManyToManyColumn',
  46. 'MarkdownColumn',
  47. 'TagColumn',
  48. 'TemplateColumn',
  49. 'ToggleColumn',
  50. 'TreeColumn',
  51. 'UtilizationColumn',
  52. )
  53. #
  54. # Django-tables2 overrides
  55. #
  56. @library.register
  57. class DateColumn(tables.Column):
  58. """
  59. Render a datetime.date in ISO 8601 format.
  60. """
  61. def render(self, value):
  62. if value:
  63. return value.isoformat()
  64. return None
  65. def value(self, value):
  66. if value:
  67. return value.isoformat()
  68. return None
  69. @classmethod
  70. def from_field(cls, field, **kwargs):
  71. if isinstance(field, DateField):
  72. return cls(**kwargs)
  73. return None
  74. @library.register
  75. class DateTimeColumn(tables.Column):
  76. """
  77. Render a datetime.datetime in ISO 8601 format.
  78. Args:
  79. timespec: Granularity specification; passed through to datetime.isoformat()
  80. """
  81. def __init__(self, *args, timespec='seconds', **kwargs):
  82. self.timespec = timespec
  83. super().__init__(*args, **kwargs)
  84. def render(self, value):
  85. if value:
  86. current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
  87. value = value.astimezone(current_tz)
  88. return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
  89. return None
  90. def value(self, value):
  91. if value:
  92. return value.isoformat()
  93. return None
  94. @classmethod
  95. def from_field(cls, field, **kwargs):
  96. if isinstance(field, DateTimeField):
  97. return cls(**kwargs)
  98. return None
  99. class DurationColumn(tables.Column):
  100. """
  101. Express a duration of time in a human-friendly format. Accepts either a timedelta or a count of
  102. minutes. Example: 437 minutes becomes "7h 17m"
  103. """
  104. def render(self, value):
  105. if not isinstance(value, timedelta):
  106. value = timedelta(minutes=value)
  107. return humanize_duration(value)
  108. def value(self, value):
  109. return value
  110. class ManyToManyColumn(tables.ManyToManyColumn):
  111. """
  112. Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.
  113. """
  114. def value(self, value):
  115. items = [self.transform(item) for item in self.filter(value)]
  116. return self.separator.join(items)
  117. class TemplateColumn(tables.TemplateColumn):
  118. """
  119. Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value
  120. is an empty string.
  121. """
  122. PLACEHOLDER = mark_safe('—')
  123. def __init__(self, export_raw=False, **kwargs):
  124. """
  125. Args:
  126. export_raw: If true, data export returns the raw field value rather than the rendered template. (Default:
  127. False)
  128. """
  129. super().__init__(**kwargs)
  130. self.export_raw = export_raw
  131. def render(self, *args, **kwargs):
  132. ret = super().render(*args, **kwargs)
  133. if not ret.strip():
  134. return self.PLACEHOLDER
  135. return ret
  136. def value(self, **kwargs):
  137. if self.export_raw:
  138. # Skip template rendering and export raw value
  139. return kwargs.get('value')
  140. ret = super().value(**kwargs)
  141. if ret == self.PLACEHOLDER:
  142. return ''
  143. return ret
  144. #
  145. # Custom columns
  146. #
  147. class ToggleColumn(tables.CheckBoxColumn):
  148. """
  149. Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
  150. """
  151. def __init__(self, *args, **kwargs):
  152. default = kwargs.pop('default', '')
  153. visible = kwargs.pop('visible', False)
  154. if 'attrs' not in kwargs:
  155. kwargs['attrs'] = {
  156. 'th': {
  157. 'class': 'w-1',
  158. 'aria-label': _('Select all'),
  159. },
  160. 'td': {
  161. 'class': 'w-1',
  162. },
  163. 'input': {
  164. 'class': 'form-check-input',
  165. 'aria-label': lambda record, value: format_lazy(_('Select {object}'), object=record),
  166. }
  167. }
  168. super().__init__(*args, default=default, visible=visible, **kwargs)
  169. @property
  170. def header(self):
  171. title_text = _('Toggle all')
  172. return format_html(
  173. '<input type="checkbox" class="toggle form-check-input" title="{}" aria-label="{}" />',
  174. title_text, title_text,
  175. )
  176. class BooleanColumn(tables.Column):
  177. """
  178. Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
  179. character.
  180. """
  181. TRUE_MARK = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold"></i></span>')
  182. FALSE_MARK = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>')
  183. EMPTY_MARK = mark_safe('<span class="text-muted">&mdash;</span>') # Placeholder
  184. def __init__(self, *args, true_mark=TRUE_MARK, false_mark=FALSE_MARK, **kwargs):
  185. self.true_mark = true_mark
  186. self.false_mark = false_mark
  187. super().__init__(*args, **kwargs)
  188. def render(self, value):
  189. if value is None:
  190. return self.EMPTY_MARK
  191. if value and self.true_mark:
  192. return self.true_mark
  193. if not value and self.false_mark:
  194. return self.false_mark
  195. return self.EMPTY_MARK
  196. def value(self, value):
  197. return str(value)
  198. @dataclass
  199. class ActionsItem:
  200. title: str
  201. icon: str
  202. permission: str | None = None
  203. css_class: str | None = 'secondary'
  204. class ActionsColumn(tables.Column):
  205. """
  206. A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include
  207. additional buttons rendered from a template string.
  208. :param actions: The ordered list of dropdown menu items to include
  209. :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
  210. :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
  211. direct button link and icon (default: True)
  212. """
  213. attrs = {
  214. 'th': {
  215. 'aria-label': _('Actions'),
  216. },
  217. 'td': {
  218. 'class': 'text-end text-nowrap noprint p-1'
  219. }
  220. }
  221. empty_values = ()
  222. actions = {
  223. 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
  224. 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
  225. 'changelog': ActionsItem('Changelog', 'history'),
  226. }
  227. def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
  228. super().__init__(*args, **kwargs)
  229. self.extra_buttons = extra_buttons
  230. self.split_actions = split_actions
  231. # Determine which actions to enable
  232. self.actions = {
  233. name: self.actions[name] for name in actions
  234. }
  235. def header(self):
  236. return ''
  237. def render(self, record, table, **kwargs):
  238. model = table.Meta.model
  239. # Skip if no actions or extra buttons are defined
  240. if not (self.actions or self.extra_buttons):
  241. return ''
  242. # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs)
  243. if not isinstance(record, model) or not getattr(record, 'pk', None):
  244. return ''
  245. if request := getattr(table, 'context', {}).get('request'):
  246. return_url = request.GET.get('return_url', request.get_full_path())
  247. url_appendix = f'?return_url={quote(return_url)}'
  248. else:
  249. url_appendix = ''
  250. html = ''
  251. # Compile actions menu
  252. button = None
  253. dropdown_class = 'secondary'
  254. dropdown_links = []
  255. user = getattr(request, 'user', AnonymousUser())
  256. for idx, (action, attrs) in enumerate(self.actions.items()):
  257. permission = get_permission_for_model(model, attrs.permission)
  258. if attrs.permission is None or user.has_perm(permission):
  259. url = get_action_url(model, action=action, kwargs={'pk': record.pk})
  260. # Render a separate button if a) only one action exists, or b) if split_actions is True
  261. if len(self.actions) == 1 or (self.split_actions and idx == 0):
  262. dropdown_class = attrs.css_class
  263. button = (
  264. f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button" '
  265. f'aria-label="{attrs.title}">'
  266. f'<i class="mdi mdi-{attrs.icon}"></i></a>'
  267. )
  268. # Add dropdown menu items
  269. else:
  270. dropdown_links.append(
  271. f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
  272. f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
  273. )
  274. # Create the actions dropdown menu
  275. toggle_text = _('Toggle Dropdown')
  276. if button and dropdown_links:
  277. html += (
  278. f'<span class="btn-group dropdown">'
  279. f' {button}'
  280. f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" '
  281. f'style="padding-left: 2px">'
  282. f' <span class="visually-hidden">{toggle_text}</span></a>'
  283. f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
  284. f'</span>'
  285. )
  286. elif button:
  287. html += button
  288. elif dropdown_links:
  289. html += (
  290. f'<span class="btn-group dropdown">'
  291. f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
  292. f' <span class="visually-hidden">{toggle_text}</span></a>'
  293. f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
  294. f'</span>'
  295. )
  296. # Render any extra buttons from template code
  297. if self.extra_buttons:
  298. template = Template(self.extra_buttons)
  299. context = getattr(table, "context", Context())
  300. context.update({'record': record})
  301. html = template.render(context) + html
  302. return mark_safe(html)
  303. class ChoiceFieldColumn(tables.Column):
  304. """
  305. Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is
  306. set by the instance's get_FOO_color() method, if defined, or can be overridden by a "color" callable.
  307. """
  308. DEFAULT_BG_COLOR = 'secondary'
  309. def __init__(self, *args, color=None, **kwargs):
  310. super().__init__(*args, **kwargs)
  311. self.color = color
  312. def render(self, record, bound_column, value):
  313. if value in self.empty_values:
  314. return self.default
  315. # Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color())
  316. if self.color:
  317. bg_color = self.color(record)
  318. else:
  319. try:
  320. bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
  321. except AttributeError:
  322. bg_color = self.DEFAULT_BG_COLOR
  323. return mark_safe(f'<span class="badge text-bg-{bg_color}">{value}</span>')
  324. def value(self, value):
  325. return value
  326. class ContentTypeColumn(tables.Column):
  327. """
  328. Display a ContentType instance.
  329. """
  330. def render(self, value):
  331. if value is None:
  332. return None
  333. return object_type_name(value, include_app=False)
  334. def value(self, value):
  335. if value is None:
  336. return None
  337. return object_type_identifier(value)
  338. class ContentTypesColumn(tables.ManyToManyColumn):
  339. """
  340. Display a list of ContentType instances.
  341. """
  342. def __init__(self, separator=None, *args, **kwargs):
  343. # Use a line break as the default separator
  344. if separator is None:
  345. separator = mark_safe('<br />')
  346. super().__init__(separator=separator, *args, **kwargs)
  347. def transform(self, obj):
  348. return object_type_name(obj, include_app=False)
  349. def value(self, value):
  350. return ','.join([
  351. object_type_identifier(ot) for ot in self.filter(value)
  352. ])
  353. class ColorColumn(tables.Column):
  354. """
  355. Display an arbitrary color value, specified in RRGGBB format.
  356. """
  357. def render(self, value):
  358. return mark_safe(
  359. f'<span class="color-label" style="background-color: #{value}">&nbsp;</span>'
  360. )
  361. def value(self, value):
  362. return f'#{value}'
  363. class ColoredLabelColumn(tables.TemplateColumn):
  364. """
  365. Render a related object as a colored label. The related object must have a `color` attribute (specifying
  366. an RRGGBB value) and a `get_absolute_url()` method.
  367. """
  368. template_code = """
  369. {% load helpers %}
  370. {% if value %}
  371. <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
  372. <a href="{{ value.get_absolute_url }}">{{ value }}</a>
  373. </span>
  374. {% else %}
  375. &mdash;
  376. {% endif %}
  377. """
  378. def __init__(self, *args, **kwargs):
  379. super().__init__(template_code=self.template_code, *args, **kwargs)
  380. def value(self, value):
  381. return str(value)
  382. class LinkedCountColumn(tables.Column):
  383. """
  384. Render a count of related objects linked to a filtered URL.
  385. :param viewname: The view name to use for URL resolution
  386. :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
  387. :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
  388. """
  389. def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
  390. self.viewname = viewname
  391. self.view_kwargs = view_kwargs or {}
  392. self.url_params = url_params
  393. super().__init__(*args, default=default, **kwargs)
  394. def render(self, record, value):
  395. if value:
  396. url = reverse(self.viewname, kwargs=self.view_kwargs)
  397. if self.url_params:
  398. url += '?' + '&'.join([
  399. f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
  400. for k, v in self.url_params.items()
  401. ])
  402. return mark_safe(f'<a href="{url}">{escape(value)}</a>')
  403. return value
  404. def value(self, value):
  405. return value
  406. class TagColumn(tables.TemplateColumn):
  407. """
  408. Display a list of Tags assigned to the object.
  409. """
  410. template_code = """
  411. {% load helpers %}
  412. {% for tag in value.all %}
  413. {% tag tag url_name %}
  414. {% empty %}
  415. <span class="text-muted">&mdash;</span>
  416. {% endfor %}
  417. """
  418. def __init__(self, url_name=None):
  419. super().__init__(
  420. orderable=False,
  421. template_code=self.template_code,
  422. extra_context={'url_name': url_name},
  423. verbose_name=_('Tags'),
  424. )
  425. def value(self, value):
  426. return ",".join([tag.name for tag in value.all()])
  427. class CustomFieldColumn(tables.Column):
  428. """
  429. Display custom fields in the appropriate format.
  430. """
  431. def __init__(self, customfield, *args, **kwargs):
  432. self.customfield = customfield
  433. kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
  434. if 'verbose_name' not in kwargs:
  435. kwargs['verbose_name'] = customfield.label or customfield.name
  436. # We can't logically sort on FK values
  437. if customfield.type in (
  438. CustomFieldTypeChoices.TYPE_OBJECT,
  439. CustomFieldTypeChoices.TYPE_MULTIOBJECT
  440. ):
  441. kwargs['orderable'] = False
  442. super().__init__(*args, **kwargs)
  443. def order(self, queryset, is_descending):
  444. # Order by the underlying JSON value, honoring the custom field's null placement preference.
  445. # A missing key or a JSON null value is extracted as SQL NULL via the ->> (text) operator,
  446. # whereas the -> (JSONB) operator used for value ordering treats JSON null as a sortable value.
  447. # We therefore annotate an explicit rank to control null placement independently of JSONB sorting.
  448. name = self.customfield.name
  449. text_value = f'_cf_{name}_text'
  450. null_rank = f'_cf_{name}_nullrank'
  451. null_sort, value_sort = (0, 1) if self.customfield.nulls_first else (1, 0)
  452. queryset = queryset.annotate(**{
  453. text_value: KeyTextTransform(name, 'custom_field_data'),
  454. }).annotate(**{
  455. null_rank: Case(
  456. When(**{f'{text_value}__isnull': True}, then=Value(null_sort)),
  457. default=Value(value_sort),
  458. output_field=IntegerField(),
  459. ),
  460. })
  461. value = F(f'custom_field_data__{name}')
  462. ordering = (null_rank, value.desc() if is_descending else value.asc())
  463. return queryset.order_by(*ordering), True
  464. @staticmethod
  465. def _linkify_item(item):
  466. if hasattr(item, 'get_absolute_url'):
  467. return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
  468. return escape(item)
  469. def render(self, value):
  470. if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
  471. return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
  472. if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
  473. return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
  474. if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
  475. # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against
  476. # dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless
  477. # (relative) value is considered safe.
  478. if url_scheme_is_allowed(value):
  479. return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
  480. return escape(value)
  481. if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
  482. if value is None:
  483. return self.default
  484. label = self.customfield.get_choice_label(value)
  485. color = self.customfield.get_choice_color(value)
  486. if color:
  487. return mark_safe(
  488. f'<span class="badge text-bg-{escape(color)}">{escape(label)}</span>'
  489. )
  490. return label
  491. if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
  492. if not value:
  493. return ''
  494. has_color = False
  495. parts = []
  496. for v in value:
  497. label = self.customfield.get_choice_label(v)
  498. color = self.customfield.get_choice_color(v)
  499. if color:
  500. has_color = True
  501. parts.append((label, color))
  502. if has_color:
  503. badges = []
  504. for label, color in parts:
  505. badges.append(
  506. f'<span class="badge text-bg-{escape(color or "secondary")}">{escape(label)}</span>'
  507. )
  508. return mark_safe(' '.join(badges))
  509. return ', '.join(label for label, _ in parts)
  510. if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
  511. return mark_safe(', '.join(
  512. self._linkify_item(obj) for obj in self.customfield.deserialize(value)
  513. ))
  514. if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
  515. return render_markdown(value)
  516. if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
  517. return parse_date(value).isoformat()
  518. if value is not None:
  519. obj = self.customfield.deserialize(value)
  520. return mark_safe(self._linkify_item(obj))
  521. return self.default
  522. def value(self, value):
  523. if isinstance(value, list):
  524. return ','.join(str(v) for v in self.customfield.deserialize(value))
  525. if value is not None:
  526. return self.customfield.deserialize(value)
  527. return self.default
  528. class CustomLinkColumn(tables.Column):
  529. """
  530. Render a custom link as a table column.
  531. """
  532. def __init__(self, customlink, *args, **kwargs):
  533. self.customlink = customlink
  534. kwargs.setdefault('accessor', Accessor('pk'))
  535. kwargs.setdefault('orderable', False)
  536. kwargs.setdefault('verbose_name', customlink.name)
  537. super().__init__(*args, **kwargs)
  538. def _render_customlink(self, record, table):
  539. context = {
  540. 'object': record,
  541. 'debug': settings.DEBUG,
  542. }
  543. if request := getattr(table, 'context', {}).get('request'):
  544. # If the request is available, include a sanitized subset of it as context
  545. context.update({
  546. 'request': get_safe_request_context(request),
  547. **auth(request),
  548. })
  549. return self.customlink.render(context)
  550. def render(self, record, table, **kwargs):
  551. try:
  552. if rendered := self._render_customlink(record, table):
  553. return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
  554. except Exception as e:
  555. error_text = _('Error')
  556. return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
  557. return ''
  558. def value(self, record, table, **kwargs):
  559. try:
  560. if rendered := self._render_customlink(record, table):
  561. return rendered['link']
  562. except Exception:
  563. pass
  564. return None
  565. class TreeColumn(tables.TemplateColumn):
  566. """
  567. Display a nested hierarchy for tree-enabled models (Region, Location, etc.).
  568. """
  569. template_code = """
  570. {% load helpers %}
  571. {% if not table.order_by %}
  572. {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
  573. {% endif %}
  574. <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
  575. """
  576. def __init__(self, *args, **kwargs):
  577. super().__init__(
  578. template_code=self.template_code,
  579. attrs={'td': {'class': 'text-nowrap'}},
  580. *args,
  581. **kwargs
  582. )
  583. def value(self, value):
  584. return value
  585. # Deprecated alias for plugin compatibility; use TreeColumn going forward.
  586. # TODO: Remove this in NetBox v5.0
  587. MPTTColumn = TreeColumn
  588. class UtilizationColumn(tables.TemplateColumn):
  589. """
  590. Display a colored utilization bar graph.
  591. """
  592. template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
  593. def __init__(self, *args, **kwargs):
  594. super().__init__(template_code=self.template_code, *args, **kwargs)
  595. def value(self, value):
  596. return f'{value}%'
  597. class MarkdownColumn(tables.TemplateColumn):
  598. """
  599. Render a Markdown string.
  600. """
  601. template_code = """
  602. {% if value %}
  603. {{ value|markdown }}
  604. {% else %}
  605. &mdash;
  606. {% endif %}
  607. """
  608. def __init__(self, **kwargs):
  609. super().__init__(
  610. template_code=self.template_code,
  611. **kwargs,
  612. )
  613. def value(self, value):
  614. return value
  615. class ArrayColumn(tables.Column):
  616. """
  617. List array items as a comma-separated list.
  618. """
  619. def __init__(self, *args, max_items=None, func=str, **kwargs):
  620. self.max_items = max_items
  621. self.func = func
  622. super().__init__(*args, **kwargs)
  623. def render(self, value):
  624. omitted_count = 0
  625. # Limit the returned items to the specified maximum number (if any)
  626. if self.max_items:
  627. omitted_count = len(value) - self.max_items
  628. value = value[:self.max_items - 1]
  629. # Apply custom processing function (if any) per item
  630. if self.func:
  631. value = [self.func(v) for v in value]
  632. # Annotate omitted items (if applicable)
  633. if omitted_count > 0:
  634. value.append(f'({omitted_count} more)')
  635. return ', '.join(value)
  636. class ChoicesColumn(tables.Column):
  637. """
  638. Display the human-friendly labels of a set of choices.
  639. """
  640. def __init__(self, *args, max_items=None, **kwargs):
  641. self.max_items = max_items
  642. super().__init__(*args, **kwargs)
  643. def render(self, value):
  644. omitted_count = 0
  645. value = [v[1] for v in value]
  646. # Limit the returned items to the specified maximum number (if any)
  647. if self.max_items:
  648. omitted_count = len(value) - self.max_items
  649. value = value[:self.max_items - 1]
  650. # Annotate omitted items (if applicable)
  651. if omitted_count > 0:
  652. value.append(f'({omitted_count} more)')
  653. return ', '.join(value)
  654. class DistanceColumn(TemplateColumn):
  655. """
  656. Distance with template code for formatting
  657. """
  658. template_code = """
  659. {% load helpers %}
  660. {% display_distance record.distance record.distance_unit record.abs_distance %}
  661. """
  662. def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
  663. super().__init__(template_code=template_code, order_by=order_by, **kwargs)
  664. class DictColumn(tables.Column):
  665. """
  666. Render a dictionary of data in a simple key: value format, one pair per line.
  667. """
  668. def render(self, value):
  669. output = '<br />'.join([
  670. f'{escape(k)}: {escape(v)}' for k, v in value.items()
  671. ])
  672. return mark_safe(output)