tables.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import django_tables2 as tables
  2. from django.conf import settings
  3. from django.contrib.auth.models import AnonymousUser
  4. from django.contrib.contenttypes.fields import GenericForeignKey
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.core.exceptions import FieldDoesNotExist
  7. from django.db.models import DateField, DateTimeField
  8. from django.db.models.fields.related import RelatedField
  9. from django.urls import reverse
  10. from django.utils.formats import date_format
  11. from django.utils.safestring import mark_safe
  12. from django_tables2 import RequestConfig
  13. from django_tables2.columns import library
  14. from django_tables2.data import TableQuerysetData
  15. from django_tables2.utils import Accessor
  16. from extras.choices import CustomFieldTypeChoices
  17. from extras.models import CustomField, CustomLink
  18. from .utils import content_type_identifier, content_type_name
  19. from .paginator import EnhancedPaginator, get_paginate_count
  20. class BaseTable(tables.Table):
  21. """
  22. Default table for object lists
  23. :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
  24. """
  25. id = tables.Column(
  26. linkify=True,
  27. verbose_name='ID'
  28. )
  29. class Meta:
  30. attrs = {
  31. 'class': 'table table-hover object-list',
  32. }
  33. def __init__(self, *args, user=None, extra_columns=None, **kwargs):
  34. if extra_columns is None:
  35. extra_columns = []
  36. # Add custom field columns
  37. obj_type = ContentType.objects.get_for_model(self._meta.model)
  38. cf_columns = [
  39. (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
  40. ]
  41. cl_columns = [
  42. (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type)
  43. ]
  44. extra_columns.extend([*cf_columns, *cl_columns])
  45. super().__init__(*args, extra_columns=extra_columns, **kwargs)
  46. # Set default empty_text if none was provided
  47. if self.empty_text is None:
  48. self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
  49. # Hide non-default columns
  50. default_columns = getattr(self.Meta, 'default_columns', list())
  51. if default_columns:
  52. for column in self.columns:
  53. if column.name not in default_columns:
  54. self.columns.hide(column.name)
  55. # Apply custom column ordering for user
  56. if user is not None and not isinstance(user, AnonymousUser):
  57. selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
  58. if selected_columns:
  59. # Show only persistent or selected columns
  60. for name, column in self.columns.items():
  61. if name in ['pk', 'actions', *selected_columns]:
  62. self.columns.show(name)
  63. else:
  64. self.columns.hide(name)
  65. # Rearrange the sequence to list selected columns first, followed by all remaining columns
  66. # TODO: There's probably a more clever way to accomplish this
  67. self.sequence = [
  68. *[c for c in selected_columns if c in self.columns.names()],
  69. *[c for c in self.columns.names() if c not in selected_columns]
  70. ]
  71. # PK column should always come first
  72. if 'pk' in self.sequence:
  73. self.sequence.remove('pk')
  74. self.sequence.insert(0, 'pk')
  75. # Actions column should always come last
  76. if 'actions' in self.sequence:
  77. self.sequence.remove('actions')
  78. self.sequence.append('actions')
  79. # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
  80. if isinstance(self.data, TableQuerysetData):
  81. prefetch_fields = []
  82. for column in self.columns:
  83. if column.visible:
  84. model = getattr(self.Meta, 'model')
  85. accessor = column.accessor
  86. prefetch_path = []
  87. for field_name in accessor.split(accessor.SEPARATOR):
  88. try:
  89. field = model._meta.get_field(field_name)
  90. except FieldDoesNotExist:
  91. break
  92. if isinstance(field, RelatedField):
  93. # Follow ForeignKeys to the related model
  94. prefetch_path.append(field_name)
  95. model = field.remote_field.model
  96. elif isinstance(field, GenericForeignKey):
  97. # Can't prefetch beyond a GenericForeignKey
  98. prefetch_path.append(field_name)
  99. break
  100. if prefetch_path:
  101. prefetch_fields.append('__'.join(prefetch_path))
  102. self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields)
  103. def _get_columns(self, visible=True):
  104. columns = []
  105. for name, column in self.columns.items():
  106. if column.visible == visible and name not in ['pk', 'actions']:
  107. columns.append((name, column.verbose_name))
  108. return columns
  109. @property
  110. def available_columns(self):
  111. return self._get_columns(visible=False)
  112. @property
  113. def selected_columns(self):
  114. return self._get_columns(visible=True)
  115. @property
  116. def objects_count(self):
  117. """
  118. Return the total number of real objects represented by the Table. This is useful when dealing with
  119. prefixes/IP addresses/etc., where some table rows may represent available address space.
  120. """
  121. if not hasattr(self, '_objects_count'):
  122. self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
  123. return self._objects_count
  124. #
  125. # Table columns
  126. #
  127. class ToggleColumn(tables.CheckBoxColumn):
  128. """
  129. Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
  130. """
  131. def __init__(self, *args, **kwargs):
  132. default = kwargs.pop('default', '')
  133. visible = kwargs.pop('visible', False)
  134. if 'attrs' not in kwargs:
  135. kwargs['attrs'] = {
  136. 'td': {
  137. 'class': 'min-width',
  138. },
  139. 'input': {
  140. 'class': 'form-check-input'
  141. }
  142. }
  143. super().__init__(*args, default=default, visible=visible, **kwargs)
  144. @property
  145. def header(self):
  146. return mark_safe('<input type="checkbox" class="toggle form-check-input" title="Toggle All" />')
  147. class BooleanColumn(tables.Column):
  148. """
  149. Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
  150. character.
  151. """
  152. def render(self, value):
  153. if value:
  154. rendered = '<span class="text-success"><i class="mdi mdi-check-bold"></i></span>'
  155. elif value is None:
  156. rendered = '<span class="text-muted">&mdash;</span>'
  157. else:
  158. rendered = '<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>'
  159. return mark_safe(rendered)
  160. def value(self, value):
  161. return str(value)
  162. class TemplateColumn(tables.TemplateColumn):
  163. """
  164. Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
  165. """
  166. PLACEHOLDER = mark_safe('&mdash;')
  167. def render(self, *args, **kwargs):
  168. ret = super().render(*args, **kwargs)
  169. if not ret.strip():
  170. return self.PLACEHOLDER
  171. return ret
  172. def value(self, **kwargs):
  173. ret = super().value(**kwargs)
  174. if ret == self.PLACEHOLDER:
  175. return ''
  176. return ret
  177. @library.register
  178. class DateColumn(tables.DateColumn):
  179. """
  180. Overrides the default implementation of DateColumn to better handle null values, returning a default value for
  181. tables and null when exporting data. It is registered in the tables library to use this class instead of the
  182. default, making this behavior consistent in all fields of type DateField.
  183. """
  184. def value(self, value):
  185. return value
  186. @classmethod
  187. def from_field(cls, field, **kwargs):
  188. if isinstance(field, DateField):
  189. return cls(**kwargs)
  190. @library.register
  191. class DateTimeColumn(tables.DateTimeColumn):
  192. """
  193. Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for
  194. tables and null when exporting data. It is registered in the tables library to use this class instead of the
  195. default, making this behavior consistent in all fields of type DateTimeField.
  196. """
  197. def value(self, value):
  198. if value:
  199. return date_format(value, format="SHORT_DATETIME_FORMAT")
  200. return None
  201. @classmethod
  202. def from_field(cls, field, **kwargs):
  203. if isinstance(field, DateTimeField):
  204. return cls(**kwargs)
  205. class ButtonsColumn(tables.TemplateColumn):
  206. """
  207. Render edit, delete, and changelog buttons for an object.
  208. :param model: Model class to use for calculating URL view names
  209. :param prepend_content: Additional template content to render in the column (optional)
  210. """
  211. buttons = ('changelog', 'edit', 'delete')
  212. attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
  213. # Note that braces are escaped to allow for string formatting prior to template rendering
  214. template_code = """
  215. {{% if "changelog" in buttons %}}
  216. <a href="{{% url '{app_label}:{model_name}_changelog' pk=record.pk %}}" class="btn btn-outline-dark btn-sm" title="Change log">
  217. <i class="mdi mdi-history"></i>
  218. </a>
  219. {{% endif %}}
  220. {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
  221. <a href="{{% url '{app_label}:{model_name}_edit' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-warning" title="Edit">
  222. <i class="mdi mdi-pencil"></i>
  223. </a>
  224. {{% endif %}}
  225. {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
  226. <a href="{{% url '{app_label}:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-danger" title="Delete">
  227. <i class="mdi mdi-trash-can-outline"></i>
  228. </a>
  229. {{% endif %}}
  230. """
  231. def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs):
  232. if prepend_template:
  233. prepend_template = prepend_template.replace('{', '{{')
  234. prepend_template = prepend_template.replace('}', '}}')
  235. self.template_code = prepend_template + self.template_code
  236. template_code = self.template_code.format(
  237. app_label=model._meta.app_label,
  238. model_name=model._meta.model_name,
  239. buttons=buttons
  240. )
  241. super().__init__(template_code=template_code, *args, **kwargs)
  242. # Exclude from export by default
  243. if 'exclude_from_export' not in kwargs:
  244. self.exclude_from_export = True
  245. self.extra_context.update({
  246. 'buttons': buttons or self.buttons,
  247. })
  248. def header(self):
  249. return ''
  250. class ChoiceFieldColumn(tables.Column):
  251. """
  252. Render a ChoiceField value inside a <span> indicating a particular CSS class. This is useful for displaying colored
  253. choices. The CSS class is derived by calling .get_FOO_class() on the row record.
  254. """
  255. def render(self, record, bound_column, value):
  256. if value:
  257. name = bound_column.name
  258. css_class = getattr(record, f'get_{name}_class')()
  259. label = getattr(record, f'get_{name}_display')()
  260. return mark_safe(
  261. f'<span class="badge bg-{css_class}">{label}</span>'
  262. )
  263. return self.default
  264. def value(self, value):
  265. return value
  266. class ContentTypeColumn(tables.Column):
  267. """
  268. Display a ContentType instance.
  269. """
  270. def render(self, value):
  271. if value is None:
  272. return None
  273. return content_type_name(value)
  274. def value(self, value):
  275. if value is None:
  276. return None
  277. return content_type_identifier(value)
  278. class ContentTypesColumn(tables.ManyToManyColumn):
  279. """
  280. Display a list of ContentType instances.
  281. """
  282. def __init__(self, separator=None, *args, **kwargs):
  283. # Use a line break as the default separator
  284. if separator is None:
  285. separator = mark_safe('<br />')
  286. super().__init__(separator=separator, *args, **kwargs)
  287. def transform(self, obj):
  288. return content_type_name(obj)
  289. def value(self, value):
  290. return ','.join([
  291. content_type_identifier(ct) for ct in self.filter(value)
  292. ])
  293. class ColorColumn(tables.Column):
  294. """
  295. Display a color (#RRGGBB).
  296. """
  297. def render(self, value):
  298. return mark_safe(
  299. f'<span class="color-label" style="background-color: #{value}">&nbsp;</span>'
  300. )
  301. def value(self, value):
  302. return f'#{value}'
  303. class ColoredLabelColumn(tables.TemplateColumn):
  304. """
  305. Render a colored label (e.g. for DeviceRoles).
  306. """
  307. template_code = """
  308. {% load helpers %}
  309. {% if value %}
  310. <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
  311. <a href="{{ value.get_absolute_url }}">{{ value }}</a>
  312. </span>
  313. {% else %}
  314. &mdash;
  315. {% endif %}
  316. """
  317. def __init__(self, *args, **kwargs):
  318. super().__init__(template_code=self.template_code, *args, **kwargs)
  319. def value(self, value):
  320. return str(value)
  321. class LinkedCountColumn(tables.Column):
  322. """
  323. Render a count of related objects linked to a filtered URL.
  324. :param viewname: The view name to use for URL resolution
  325. :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
  326. :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
  327. """
  328. def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs):
  329. self.viewname = viewname
  330. self.view_kwargs = view_kwargs or {}
  331. self.url_params = url_params
  332. super().__init__(*args, default=default, **kwargs)
  333. def render(self, record, value):
  334. if value:
  335. url = reverse(self.viewname, kwargs=self.view_kwargs)
  336. if self.url_params:
  337. url += '?' + '&'.join([
  338. f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
  339. for k, v in self.url_params.items()
  340. ])
  341. return mark_safe(f'<a href="{url}">{value}</a>')
  342. return value
  343. def value(self, value):
  344. return value
  345. class TagColumn(tables.TemplateColumn):
  346. """
  347. Display a list of tags assigned to the object.
  348. """
  349. template_code = """
  350. {% load helpers %}
  351. {% for tag in value.all %}
  352. {% tag tag url_name=url_name %}
  353. {% empty %}
  354. <span class="text-muted">&mdash;</span>
  355. {% endfor %}
  356. """
  357. def __init__(self, url_name=None):
  358. super().__init__(
  359. orderable=False,
  360. template_code=self.template_code,
  361. extra_context={'url_name': url_name}
  362. )
  363. def value(self, value):
  364. return ",".join([tag.name for tag in value.all()])
  365. class CustomFieldColumn(tables.Column):
  366. """
  367. Display custom fields in the appropriate format.
  368. """
  369. def __init__(self, customfield, *args, **kwargs):
  370. self.customfield = customfield
  371. kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
  372. if 'verbose_name' not in kwargs:
  373. kwargs['verbose_name'] = customfield.label or customfield.name
  374. super().__init__(*args, **kwargs)
  375. def render(self, value):
  376. if isinstance(value, list):
  377. return ', '.join(v for v in value)
  378. elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
  379. return mark_safe('<i class="mdi mdi-check-bold text-success"></i>')
  380. elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
  381. return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
  382. elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
  383. return mark_safe(f'<a href="{value}">{value}</a>')
  384. if value is not None:
  385. return value
  386. return self.default
  387. def value(self, value):
  388. if isinstance(value, list):
  389. return ','.join(v for v in value)
  390. if value is not None:
  391. return value
  392. return self.default
  393. class CustomLinkColumn(tables.Column):
  394. """
  395. Render a custom links as a table column.
  396. """
  397. def __init__(self, customlink, *args, **kwargs):
  398. self.customlink = customlink
  399. kwargs['accessor'] = Accessor('pk')
  400. if 'verbose_name' not in kwargs:
  401. kwargs['verbose_name'] = customlink.name
  402. super().__init__(*args, **kwargs)
  403. def render(self, record):
  404. try:
  405. rendered = self.customlink.render({'obj': record})
  406. if rendered:
  407. return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
  408. except Exception as e:
  409. return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
  410. return ''
  411. def value(self, record):
  412. try:
  413. rendered = self.customlink.render({'obj': record})
  414. if rendered:
  415. return rendered['link']
  416. except Exception:
  417. pass
  418. return None
  419. class MPTTColumn(tables.TemplateColumn):
  420. """
  421. Display a nested hierarchy for MPTT-enabled models.
  422. """
  423. template_code = """
  424. {% load helpers %}
  425. {% for i in record.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
  426. <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
  427. """
  428. def __init__(self, *args, **kwargs):
  429. super().__init__(
  430. template_code=self.template_code,
  431. orderable=False,
  432. attrs={'td': {'class': 'text-nowrap'}},
  433. *args,
  434. **kwargs
  435. )
  436. def value(self, value):
  437. return value
  438. class UtilizationColumn(tables.TemplateColumn):
  439. """
  440. Display a colored utilization bar graph.
  441. """
  442. template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}"""
  443. def __init__(self, *args, **kwargs):
  444. super().__init__(template_code=self.template_code, *args, **kwargs)
  445. def value(self, value):
  446. return f'{value}%'
  447. class MarkdownColumn(tables.TemplateColumn):
  448. """
  449. Render a Markdown string.
  450. """
  451. template_code = """
  452. {% load helpers %}
  453. {% if value %}
  454. {{ value|render_markdown }}
  455. {% else %}
  456. &mdash;
  457. {% endif %}
  458. """
  459. def __init__(self):
  460. super().__init__(
  461. template_code=self.template_code
  462. )
  463. def value(self, value):
  464. return value
  465. #
  466. # Pagination
  467. #
  468. def paginate_table(table, request):
  469. """
  470. Paginate a table given a request context.
  471. """
  472. paginate = {
  473. 'paginator_class': EnhancedPaginator,
  474. 'per_page': get_paginate_count(request)
  475. }
  476. RequestConfig(request, paginate).configure(table)
  477. #
  478. # Callables
  479. #
  480. def linkify_email(value):
  481. if value is None:
  482. return None
  483. return f"mailto:{value}"
  484. def linkify_phone(value):
  485. if value is None:
  486. return None
  487. return f"tel:{value}"