Coverage for netbox/tables/tables.py: 27%
245 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
1from copy import deepcopy
2from functools import cached_property
3from urllib.parse import urlencode
5import django_tables2 as tables
6from django.conf import settings
7from django.contrib.contenttypes.fields import GenericForeignKey
8from django.core.exceptions import FieldDoesNotExist
9from django.db.models.fields.related import RelatedField
10from django.db.models.fields.reverse_related import ManyToOneRel
11from django.urls.exceptions import NoReverseMatch
12from django.utils.safestring import mark_safe
13from django.utils.translation import gettext_lazy as _
14from django_tables2.data import TableQuerysetData
15from django_tables2.utils import OrderBy
17from core.models import ObjectType
18from extras.choices import *
19from extras.models import CustomField, CustomLink
20from netbox.constants import EMPTY_TABLE_TEXT
21from netbox.registry import registry
22from netbox.tables import columns
23from utilities.html import highlight
24from utilities.paginator import EnhancedPaginator, get_paginate_count
25from utilities.string import title
26from utilities.views import get_action_url
28from .template_code import *
30__all__ = (
31 'BaseTable',
32 'NestedGroupModelTable',
33 'NetBoxTable',
34 'OrganizationalModelTable',
35 'PrimaryModelTable',
36 'SearchTable',
37)
40class BaseTable(tables.Table):
41 """
42 Base table class for NetBox objects. Adds support for:
44 * User configuration (column preferences)
45 * Automatic prefetching of related objects
46 * BS5 styling
48 :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
49 """
50 exempt_columns = ()
52 class Meta:
53 attrs = {
54 'class': 'table table-hover object-list',
55 }
57 # TODO: Remove user kwarg in NetBox v4.7
58 def __init__(self, *args, **kwargs):
59 super().__init__(*args, **kwargs)
61 # Set default empty_text if none was provided
62 if self.empty_text is None:
63 self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)
65 def _get_columns(self, visible=True):
66 columns = []
67 for name, column in self.columns.items():
68 if column.visible == visible and name not in self.exempt_columns:
69 columns.append((name, column.verbose_name))
70 return columns
72 @property
73 def name(self):
74 return self.__class__.__name__
76 @property
77 def available_columns(self):
78 return sorted(self._get_columns(visible=False))
80 @property
81 def selected_columns(self):
82 return self._get_columns(visible=True)
84 @property
85 def objects_count(self):
86 """
87 Return the total number of real objects represented by the Table. This is useful when dealing with
88 prefixes/IP addresses/etc., where some table rows may represent available address space.
89 """
90 if not hasattr(self, '_objects_count'):
91 self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
92 return self._objects_count
94 def _set_columns(self, selected_columns):
95 """
96 Update the table sequence to display only the named columns and any exempt columns.
97 """
98 # Hide non-selected columns which are not exempt
99 for column in self.columns:
100 if column.name not in [*selected_columns, *self.exempt_columns]:
101 self.columns.hide(column.name)
103 # Rearrange the sequence to list selected columns first, followed by all remaining columns
104 # TODO: There's probably a more clever way to accomplish this
105 self.sequence = [
106 *[c for c in selected_columns if c in self.columns.names()],
107 *[c for c in self.columns.names() if c not in selected_columns]
108 ]
110 # PK column should always come first
111 if 'pk' in self.sequence:
112 self.sequence.remove('pk')
113 self.sequence.insert(0, 'pk')
115 # Actions column should always come last
116 if 'actions' in self.sequence:
117 self.sequence.remove('actions')
118 self.sequence.append('actions')
120 def _apply_prefetching(self, columns=None):
121 """
122 Dynamically update the table's QuerySet to ensure related fields are pre-fetched.
124 Args:
125 columns: An optional iterable of column names for which to apply prefetching,
126 regardless of visibility. If None, only currently visible columns are used.
127 """
128 if not isinstance(self.data, TableQuerysetData):
129 return
131 prefetch_fields = []
132 for column in self.columns.iterall():
133 if columns is not None:
134 if column.name not in columns:
135 continue
136 elif not column.visible:
137 # Skip hidden columns
138 continue
139 model = getattr(self.Meta, 'model') # Must be called *after* resolving columns
140 accessor = column.accessor
141 if accessor.startswith('custom_field_data__'):
142 # Ignore custom field references
143 continue
144 prefetch_path = []
145 for field_name in accessor.split(accessor.SEPARATOR):
146 try:
147 field = model._meta.get_field(field_name)
148 except FieldDoesNotExist:
149 break
150 if isinstance(field, (RelatedField, ManyToOneRel)):
151 # Follow ForeignKeys to the related model
152 prefetch_path.append(field_name)
153 model = field.remote_field.model
154 elif isinstance(field, GenericForeignKey):
155 # Can't prefetch beyond a GenericForeignKey
156 prefetch_path.append(field_name)
157 break
158 if prefetch_path:
159 prefetch_fields.append('__'.join(prefetch_path))
160 self.data.data = self.data.data.prefetch_related(*prefetch_fields)
162 def _get_custom_field_ordering_columns(self, order_by):
163 """
164 Return the custom field columns among those named by the given ordering.
166 Args:
167 order_by: An iterable (or comma-separated string) of order by aliases.
168 """
169 order_by = order_by.split(',') if isinstance(order_by, str) else order_by or ()
170 ordering_columns = []
171 for alias in order_by:
172 name = OrderBy(alias).bare
173 # Ignore any aliases which django-tables2 will itself discard
174 if name not in self.columns or not self.columns[name].orderable:
175 continue
176 if isinstance(column := self.columns[name].column, columns.CustomFieldColumn):
177 ordering_columns.append(column)
178 return ordering_columns
180 def _apply_ordering_annotations(self, ordering_columns):
181 """
182 Dynamically annotate the table's QuerySet with the expressions needed to sort by the given
183 custom field columns. These are applied only for the columns actually being ordered by, to
184 avoid burdening every query with expressions it has no use for.
185 """
186 annotations = {}
187 for column in ordering_columns:
188 annotations.update(column.get_ordering_annotation())
190 # Skip any annotations already applied, as when the ordering is set more than once
191 if annotations := {
192 name: expr for name, expr in annotations.items()
193 if name not in self.data.data.query.annotations
194 }:
195 self.data.data = self.data.data.annotate(**annotations)
197 def _apply_ordering_tie_breaker(self):
198 """
199 Append the primary key to the table's ordering as a final sort key, so that the ordering is
200 total. Rows tying on every preceding key -- and every object holding no value for a custom
201 field ties on both of that column's keys -- are otherwise free to come back in a different
202 order for each query, which would cause paginated results to skip or repeat rows from one
203 page to the next.
204 """
205 ordering = self.data.data.query.order_by
206 if ordering and not any(OrderBy(o).bare in ('pk', 'id') for o in ordering):
207 self.data.data = self.data.data.order_by(*ordering, 'pk')
209 @tables.Table.order_by.setter
210 def order_by(self, value):
211 """
212 Extend the ordering of the table's data with the support needed by custom field columns.
213 """
214 if not isinstance(self.data, TableQuerysetData):
215 tables.Table.order_by.fset(self, value)
216 return
218 if ordering_columns := self._get_custom_field_ordering_columns(value):
219 self._apply_ordering_annotations(ordering_columns)
220 tables.Table.order_by.fset(self, value)
221 if ordering_columns:
222 self._apply_ordering_tie_breaker()
224 def configure(self, request):
225 """
226 Configure the table for a specific request context. This performs pagination and records
227 the user's preferred columns & ordering logic.
228 """
229 columns = None
230 ordering = None
232 if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
233 if request.GET[self.prefixed_order_by_field]:
234 # If an ordering has been specified as a query parameter, save it as the
235 # user's preferred ordering for this table.
236 ordering = request.GET.getlist(self.prefixed_order_by_field)
237 request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
238 else:
239 # If the ordering has been set to none (empty), clear any existing preference.
240 request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
242 # If the user has a saved preference, apply it
243 if request.user.is_authenticated and (userconfig := request.user.config):
244 if columns is None:
245 columns = userconfig.get(f"tables.{self.name}.columns")
246 if ordering is None:
247 ordering = userconfig.get(f"tables.{self.name}.ordering")
248 if userconfig.get("ui.tables.striping"):
249 self.attrs['class'] += ' table-striped'
251 # Fall back to the default columns & ordering
252 if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
253 columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
254 if columns is None:
255 columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
257 self._set_columns(columns)
259 # Apply column inclusion/exclusion (overrides user preferences)
260 if columns_param := request.GET.get('include_columns'):
261 for column_name in columns_param.split(','):
262 if column_name in self.columns.names():
263 self.columns.show(column_name)
264 if exclude_columns := request.GET.get('exclude_columns'):
265 exclude_columns = exclude_columns.split(',')
266 for column_name in exclude_columns:
267 if column_name in self.columns.names() and column_name not in self.exempt_columns:
268 self.columns.hide(column_name)
270 self._apply_prefetching()
271 if ordering is not None:
272 self.order_by = ordering
274 # Paginate the table results
275 paginate = {
276 'paginator_class': EnhancedPaginator,
277 'per_page': get_paginate_count(request)
278 }
279 tables.RequestConfig(request, paginate).configure(self)
281 @property
282 def configuration(self):
283 config = {
284 'columns': ','.join([c[0] for c in self.selected_columns]),
285 }
286 if self.order_by:
287 config['ordering'] = self.order_by
288 return config
290 @property
291 def config_params(self):
292 if not (model := getattr(self.Meta, 'model', None)):
293 return None
294 return urlencode({
295 'object_type': ObjectType.objects.get_for_model(model).pk,
296 'table': self.name,
297 **self.configuration,
298 })
301class NetBoxTable(BaseTable):
302 """
303 Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes
304 default columns for:
306 * PK (row selection)
307 * ID
308 * Actions
309 """
310 pk = columns.ToggleColumn(
311 visible=False
312 )
313 id = tables.Column(
314 linkify=True,
315 verbose_name=_('ID')
316 )
317 actions = columns.ActionsColumn()
319 exempt_columns = ('pk', 'actions')
320 embedded = False
322 class Meta(BaseTable.Meta):
323 pass
325 def __init__(self, *args, extra_columns=None, **kwargs):
326 if extra_columns is None:
327 extra_columns = []
329 if registered_columns := registry['tables'].get(self.__class__):
330 extra_columns.extend([
331 # Create a copy to avoid modifying the original Column
332 (name, deepcopy(column)) for name, column in registered_columns.items()
333 ])
335 # Add columns for custom fields
336 custom_fields = [
337 cf for cf in CustomField.objects.get_for_model(self._meta.model)
338 if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
339 ]
340 extra_columns.extend([
341 (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
342 ])
344 # Add columns for custom links
345 object_type = ObjectType.objects.get_for_model(self._meta.model)
346 custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True)
347 extra_columns.extend([
348 (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links
349 ])
351 super().__init__(*args, extra_columns=extra_columns, **kwargs)
353 def configure(self, request):
354 # Remove custom link columns referencing CustomLinks the user cannot view (#22439).
355 # These columns are added for all enabled CustomLinks in __init__(), before the request
356 # (and thus the user) is known, so object-level permissions are enforced here instead.
357 self._restrict_customlink_columns(request.user)
359 super().configure(request)
361 def _restrict_customlink_columns(self, user):
362 """
363 Exclude any custom link columns which reference a CustomLink the user does not have
364 permission to view.
365 """
366 customlinks = {
367 name: column.column.customlink
368 for name, column in self.columns.iteritems()
369 if isinstance(column.column, columns.CustomLinkColumn)
370 }
371 if not customlinks:
372 return
374 permitted = set(
375 CustomLink.objects.restrict(user, 'view').filter(
376 pk__in=[cl.pk for cl in customlinks.values()]
377 ).values_list('pk', flat=True)
378 )
379 excluded = tuple(
380 name for name, customlink in customlinks.items() if customlink.pk not in permitted
381 )
382 if excluded:
383 self.exclude = (*self.exclude, *excluded)
385 @cached_property
386 def htmx_url(self):
387 """
388 Return the base HTML request URL for embedded tables.
389 """
390 if self.embedded:
391 try:
392 return get_action_url(self._meta.model, action='list')
393 except NoReverseMatch:
394 pass
395 return ''
398class PrimaryModelTable(NetBoxTable):
399 owner_group = tables.Column(
400 accessor='owner__group',
401 linkify=True,
402 verbose_name=_('Owner Group'),
403 )
404 owner = tables.Column(
405 linkify=True,
406 verbose_name=_('Owner'),
407 )
408 comments = columns.MarkdownColumn(
409 verbose_name=_('Comments'),
410 )
413class OrganizationalModelTable(NetBoxTable):
414 owner_group = tables.Column(
415 accessor='owner__group',
416 linkify=True,
417 verbose_name=_('Owner Group'),
418 )
419 owner = tables.Column(
420 linkify=True,
421 verbose_name=_('Owner'),
422 )
423 comments = columns.MarkdownColumn(
424 verbose_name=_('Comments'),
425 )
428class NestedGroupModelTable(NetBoxTable):
429 owner_group = tables.Column(
430 accessor='owner__group',
431 linkify=True,
432 verbose_name=_('Owner Group'),
433 )
434 owner = tables.Column(
435 linkify=True,
436 verbose_name=_('Owner'),
437 )
438 name = columns.TreeColumn(
439 verbose_name=_('Name'),
440 linkify=True
441 )
442 parent = tables.Column(
443 verbose_name=_('Parent'),
444 linkify=True,
445 )
446 comments = columns.MarkdownColumn(
447 verbose_name=_('Comments'),
448 )
451class SearchTable(tables.Table):
452 object_type = columns.ContentTypeColumn(
453 verbose_name=_('Type'),
454 order_by="object___meta__verbose_name",
455 )
456 object = tables.Column(
457 verbose_name=_('Object'),
458 linkify=True,
459 order_by=('name', )
460 )
461 field = tables.Column(
462 verbose_name=_('Field'),
463 )
464 value = tables.Column(
465 verbose_name=_('Value'),
466 )
467 attrs = columns.TemplateColumn(
468 template_code=SEARCH_RESULT_ATTRS,
469 verbose_name=_('Attributes')
470 )
472 trim_length = 30
474 class Meta:
475 attrs = {
476 'class': 'table table-hover object-list',
477 }
478 empty_text = _(EMPTY_TABLE_TEXT)
480 def __init__(self, data, highlight=None, **kwargs):
481 self.highlight = highlight
482 super().__init__(data, **kwargs)
484 def render_field(self, value, record):
485 try:
486 model_field = record.object._meta.get_field(value)
487 return title(model_field.verbose_name)
488 except FieldDoesNotExist:
489 return value
491 def render_value(self, value):
492 if not self.highlight:
493 return value
495 value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length)
497 return mark_safe(value)