columns.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. from dataclasses import dataclass
  2. from typing import Optional
  3. import django_tables2 as tables
  4. from django.conf import settings
  5. from django.contrib.auth.models import AnonymousUser
  6. from django.template import Context, Template
  7. from django.urls import reverse
  8. from django.utils.safestring import mark_safe
  9. from django_tables2.utils import Accessor
  10. from extras.choices import CustomFieldTypeChoices
  11. from utilities.utils import content_type_identifier, content_type_name
  12. __all__ = (
  13. 'ActionsColumn',
  14. 'BooleanColumn',
  15. 'ChoiceFieldColumn',
  16. 'ColorColumn',
  17. 'ColoredLabelColumn',
  18. 'ContentTypeColumn',
  19. 'ContentTypesColumn',
  20. 'CustomFieldColumn',
  21. 'CustomLinkColumn',
  22. 'LinkedCountColumn',
  23. 'MarkdownColumn',
  24. 'MPTTColumn',
  25. 'TagColumn',
  26. 'TemplateColumn',
  27. 'ToggleColumn',
  28. 'UtilizationColumn',
  29. )
  30. class ToggleColumn(tables.CheckBoxColumn):
  31. """
  32. Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
  33. """
  34. def __init__(self, *args, **kwargs):
  35. default = kwargs.pop('default', '')
  36. visible = kwargs.pop('visible', False)
  37. if 'attrs' not in kwargs:
  38. kwargs['attrs'] = {
  39. 'td': {
  40. 'class': 'min-width',
  41. },
  42. 'input': {
  43. 'class': 'form-check-input'
  44. }
  45. }
  46. super().__init__(*args, default=default, visible=visible, **kwargs)
  47. @property
  48. def header(self):
  49. return mark_safe('<input type="checkbox" class="toggle form-check-input" title="Toggle All" />')
  50. class BooleanColumn(tables.Column):
  51. """
  52. Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
  53. character.
  54. """
  55. def render(self, value):
  56. if value:
  57. rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
  58. elif value is None:
  59. rendered = '<span class="text-muted">&mdash;</span>'
  60. else:
  61. rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
  62. return mark_safe(rendered)
  63. def value(self, value):
  64. return str(value)
  65. class TemplateColumn(tables.TemplateColumn):
  66. """
  67. Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
  68. """
  69. PLACEHOLDER = mark_safe('&mdash;')
  70. def render(self, *args, **kwargs):
  71. ret = super().render(*args, **kwargs)
  72. if not ret.strip():
  73. return self.PLACEHOLDER
  74. return ret
  75. def value(self, **kwargs):
  76. ret = super().value(**kwargs)
  77. if ret == self.PLACEHOLDER:
  78. return ''
  79. return ret
  80. @dataclass
  81. class ActionsItem:
  82. title: str
  83. icon: str
  84. permission: Optional[str] = None
  85. class ActionsColumn(tables.Column):
  86. """
  87. A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include
  88. additional buttons rendered from a template string.
  89. :param sequence: The ordered list of dropdown menu items to include
  90. :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
  91. """
  92. attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
  93. empty_values = ()
  94. actions = {
  95. 'edit': ActionsItem('Edit', 'pencil', 'change'),
  96. 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
  97. 'changelog': ActionsItem('Changelog', 'history'),
  98. }
  99. def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
  100. super().__init__(*args, **kwargs)
  101. self.extra_buttons = extra_buttons
  102. # Determine which actions to enable
  103. self.actions = {
  104. name: self.actions[name] for name in sequence
  105. }
  106. def header(self):
  107. return ''
  108. def render(self, record, table, **kwargs):
  109. # Skip dummy records (e.g. available VLANs) or those with no actions
  110. if not getattr(record, 'pk', None) or not self.actions:
  111. return ''
  112. model = table.Meta.model
  113. viewname_base = f'{model._meta.app_label}:{model._meta.model_name}'
  114. request = getattr(table, 'context', {}).get('request')
  115. url_appendix = f'?return_url={request.path}' if request else ''
  116. links = []
  117. user = getattr(request, 'user', AnonymousUser())
  118. for action, attrs in self.actions.items():
  119. permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
  120. if attrs.permission is None or user.has_perm(permission):
  121. url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk})
  122. links.append(f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
  123. f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
  124. if not links:
  125. return ''
  126. menu = f'<span class="dropdown">' \
  127. f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
  128. f'<i class="mdi mdi-wrench"></i></a>' \
  129. f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
  130. # Render any extra buttons from template code
  131. if self.extra_buttons:
  132. template = Template(self.extra_buttons)
  133. context = getattr(table, "context", Context())
  134. context.update({'record': record})
  135. menu = template.render(context) + menu
  136. return mark_safe(menu)
  137. class ChoiceFieldColumn(tables.Column):
  138. """
  139. Render a ChoiceField value inside a <span> indicating a particular CSS class. This is useful for displaying colored
  140. choices. The CSS class is derived by calling .get_FOO_class() on the row record.
  141. """
  142. def render(self, record, bound_column, value):
  143. if value:
  144. name = bound_column.name
  145. css_class = getattr(record, f'get_{name}_class')()
  146. label = getattr(record, f'get_{name}_display')()
  147. return mark_safe(
  148. f'<span class="badge bg-{css_class}">{label}</span>'
  149. )
  150. return self.default
  151. def value(self, value):
  152. return value
  153. class ContentTypeColumn(tables.Column):
  154. """
  155. Display a ContentType instance.
  156. """
  157. def render(self, value):
  158. if value is None:
  159. return None
  160. return content_type_name(value)
  161. def value(self, value):
  162. if value is None:
  163. return None
  164. return content_type_identifier(value)
  165. class ContentTypesColumn(tables.ManyToManyColumn):
  166. """
  167. Display a list of ContentType instances.
  168. """
  169. def __init__(self, separator=None, *args, **kwargs):
  170. # Use a line break as the default separator
  171. if separator is None:
  172. separator = mark_safe('<br />')
  173. super().__init__(separator=separator, *args, **kwargs)
  174. def transform(self, obj):
  175. return content_type_name(obj)
  176. def value(self, value):
  177. return ','.join([
  178. content_type_identifier(ct) for ct in self.filter(value)
  179. ])
  180. class ColorColumn(tables.Column):
  181. """
  182. Display a color (#RRGGBB).
  183. """
  184. def render(self, value):
  185. return mark_safe(
  186. f'<span class="color-label" style="background-color: #{value}">&nbsp;</span>'
  187. )
  188. def value(self, value):
  189. return f'#{value}'
  190. class ColoredLabelColumn(tables.TemplateColumn):
  191. """
  192. Render a colored label (e.g. for DeviceRoles).
  193. """
  194. template_code = """
  195. {% load helpers %}
  196. {% if value %}
  197. <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
  198. <a href="{{ value.get_absolute_url }}">{{ value }}</a>
  199. </span>
  200. {% else %}
  201. &mdash;
  202. {% endif %}
  203. """
  204. def __init__(self, *args, **kwargs):
  205. super().__init__(template_code=self.template_code, *args, **kwargs)
  206. def value(self, value):
  207. return str(value)
  208. class LinkedCountColumn(tables.Column):
  209. """
  210. Render a count of related objects linked to a filtered URL.
  211. :param viewname: The view name to use for URL resolution
  212. :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
  213. :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
  214. """
  215. def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
  216. self.viewname = viewname
  217. self.view_kwargs = view_kwargs or {}
  218. self.url_params = url_params
  219. super().__init__(*args, default=default, **kwargs)
  220. def render(self, record, value):
  221. if value:
  222. url = reverse(self.viewname, kwargs=self.view_kwargs)
  223. if self.url_params:
  224. url += '?' + '&'.join([
  225. f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
  226. for k, v in self.url_params.items()
  227. ])
  228. return mark_safe(f'<a href="{url}">{value}</a>')
  229. return value
  230. def value(self, value):
  231. return value
  232. class TagColumn(tables.TemplateColumn):
  233. """
  234. Display a list of tags assigned to the object.
  235. """
  236. template_code = """
  237. {% load helpers %}
  238. {% for tag in value.all %}
  239. {% tag tag url_name=url_name %}
  240. {% empty %}
  241. <span class="text-muted">&mdash;</span>
  242. {% endfor %}
  243. """
  244. def __init__(self, url_name=None):
  245. super().__init__(
  246. template_code=self.template_code,
  247. extra_context={'url_name': url_name}
  248. )
  249. def value(self, value):
  250. return ",".join([tag.name for tag in value.all()])
  251. class CustomFieldColumn(tables.Column):
  252. """
  253. Display custom fields in the appropriate format.
  254. """
  255. def __init__(self, customfield, *args, **kwargs):
  256. self.customfield = customfield
  257. kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
  258. if 'verbose_name' not in kwargs:
  259. kwargs['verbose_name'] = customfield.label or customfield.name
  260. super().__init__(*args, **kwargs)
  261. def render(self, value):
  262. if isinstance(value, list):
  263. return ', '.join(v for v in value)
  264. elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
  265. # Linkify custom URLs
  266. return mark_safe(f'<a href="{value}">{value}</a>')
  267. if value is not None:
  268. return value
  269. return self.default
  270. class CustomLinkColumn(tables.Column):
  271. """
  272. Render a custom links as a table column.
  273. """
  274. def __init__(self, customlink, *args, **kwargs):
  275. self.customlink = customlink
  276. kwargs['accessor'] = Accessor('pk')
  277. if 'verbose_name' not in kwargs:
  278. kwargs['verbose_name'] = customlink.name
  279. super().__init__(*args, **kwargs)
  280. def render(self, record):
  281. try:
  282. rendered = self.customlink.render({'obj': record})
  283. if rendered:
  284. return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
  285. except Exception as e:
  286. return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
  287. return ''
  288. def value(self, record):
  289. try:
  290. rendered = self.customlink.render({'obj': record})
  291. if rendered:
  292. return rendered['link']
  293. except Exception:
  294. pass
  295. return None
  296. class MPTTColumn(tables.TemplateColumn):
  297. """
  298. Display a nested hierarchy for MPTT-enabled models.
  299. """
  300. template_code = """
  301. {% load helpers %}
  302. {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
  303. <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
  304. """
  305. def __init__(self, *args, **kwargs):
  306. super().__init__(
  307. template_code=self.template_code,
  308. orderable=False,
  309. attrs={'td': {'class': 'text-nowrap'}},
  310. *args,
  311. **kwargs
  312. )
  313. def value(self, value):
  314. return value
  315. class UtilizationColumn(tables.TemplateColumn):
  316. """
  317. Display a colored utilization bar graph.
  318. """
  319. template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
  320. def __init__(self, *args, **kwargs):
  321. super().__init__(template_code=self.template_code, *args, **kwargs)
  322. def value(self, value):
  323. return f'{value}%'
  324. class MarkdownColumn(tables.TemplateColumn):
  325. """
  326. Render a Markdown string.
  327. """
  328. template_code = """
  329. {% load helpers %}
  330. {% if value %}
  331. {{ value|render_markdown }}
  332. {% else %}
  333. &mdash;
  334. {% endif %}
  335. """
  336. def __init__(self):
  337. super().__init__(
  338. template_code=self.template_code
  339. )
  340. def value(self, value):
  341. return value