From 00a8fd654eb4eab47972c3ad7ca2b5bc6752d3e4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 7 Jan 2022 09:12:48 -0500 Subject: [PATCH] Refactor table utilities --- netbox/utilities/tables/__init__.py | 30 +++ .../{tables.py => tables/columns.py} | 188 ++---------------- netbox/utilities/tables/tables.py | 138 +++++++++++++ 3 files changed, 188 insertions(+), 168 deletions(-) create mode 100644 netbox/utilities/tables/__init__.py rename netbox/utilities/{tables.py => tables/columns.py} (68%) create mode 100644 netbox/utilities/tables/tables.py diff --git a/netbox/utilities/tables/__init__.py b/netbox/utilities/tables/__init__.py new file mode 100644 index 000000000..37dd75144 --- /dev/null +++ b/netbox/utilities/tables/__init__.py @@ -0,0 +1,30 @@ +from django_tables2 import RequestConfig + +from utilities.paginator import EnhancedPaginator, get_paginate_count +from .columns import * +from .tables import * + + +# +# Pagination +# + +def paginate_table(table, request): + """ + Paginate a table given a request context. + """ + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) + + +# +# Callables +# + +def linkify_phone(value): + if value is None: + return None + return f"tel:{value}" diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables/columns.py similarity index 68% rename from netbox/utilities/tables.py rename to netbox/utilities/tables/columns.py index 15cbb77c8..177f3bd5b 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables/columns.py @@ -2,150 +2,33 @@ from collections import namedtuple import django_tables2 as tables from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist -from django.db.models.fields.related import RelatedField from django.urls import reverse from django.utils.safestring import mark_safe -from django_tables2 import RequestConfig -from django_tables2.data import TableQuerysetData from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices -from extras.models import CustomField, CustomLink -from .utils import content_type_identifier, content_type_name -from .paginator import EnhancedPaginator, get_paginate_count +from utilities.utils import content_type_identifier, content_type_name +__all__ = ( + 'ActionsColumn', + 'BooleanColumn', + 'ButtonsColumn', + 'ChoiceFieldColumn', + 'ColorColumn', + 'ColoredLabelColumn', + 'ContentTypeColumn', + 'ContentTypesColumn', + 'CustomFieldColumn', + 'CustomLinkColumn', + 'LinkedCountColumn', + 'MarkdownColumn', + 'MPTTColumn', + 'TagColumn', + 'TemplateColumn', + 'ToggleColumn', + 'UtilizationColumn', +) -class BaseTable(tables.Table): - """ - Default table for object lists - - :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. - """ - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - - class Meta: - attrs = { - 'class': 'table table-hover object-list', - } - - def __init__(self, *args, user=None, extra_columns=None, **kwargs): - if extra_columns is None: - extra_columns = [] - - # Add custom field columns - obj_type = ContentType.objects.get_for_model(self._meta.model) - cf_columns = [ - (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) - ] - cl_columns = [ - (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) - ] - extra_columns.extend([*cf_columns, *cl_columns]) - - super().__init__(*args, extra_columns=extra_columns, **kwargs) - - # Set default empty_text if none was provided - if self.empty_text is None: - self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" - - # Hide non-default columns - default_columns = getattr(self.Meta, 'default_columns', list()) - if default_columns: - for column in self.columns: - if column.name not in default_columns: - self.columns.hide(column.name) - - # Apply custom column ordering for user - if user is not None and not isinstance(user, AnonymousUser): - selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") - if selected_columns: - - # Show only persistent or selected columns - for name, column in self.columns.items(): - if name in ['pk', 'actions', *selected_columns]: - self.columns.show(name) - else: - self.columns.hide(name) - - # Rearrange the sequence to list selected columns first, followed by all remaining columns - # TODO: There's probably a more clever way to accomplish this - self.sequence = [ - *[c for c in selected_columns if c in self.columns.names()], - *[c for c in self.columns.names() if c not in selected_columns] - ] - - # PK column should always come first - if 'pk' in self.sequence: - self.sequence.remove('pk') - self.sequence.insert(0, 'pk') - - # Actions column should always come last - if 'actions' in self.sequence: - self.sequence.remove('actions') - self.sequence.append('actions') - - # Dynamically update the table's QuerySet to ensure related fields are pre-fetched - if isinstance(self.data, TableQuerysetData): - - prefetch_fields = [] - for column in self.columns: - if column.visible: - model = getattr(self.Meta, 'model') - accessor = column.accessor - prefetch_path = [] - for field_name in accessor.split(accessor.SEPARATOR): - try: - field = model._meta.get_field(field_name) - except FieldDoesNotExist: - break - if isinstance(field, RelatedField): - # Follow ForeignKeys to the related model - prefetch_path.append(field_name) - model = field.remote_field.model - elif isinstance(field, GenericForeignKey): - # Can't prefetch beyond a GenericForeignKey - prefetch_path.append(field_name) - break - if prefetch_path: - prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) - - def _get_columns(self, visible=True): - columns = [] - for name, column in self.columns.items(): - if column.visible == visible and name not in ['pk', 'actions']: - columns.append((name, column.verbose_name)) - return columns - - @property - def available_columns(self): - return self._get_columns(visible=False) - - @property - def selected_columns(self): - return self._get_columns(visible=True) - - @property - def objects_count(self): - """ - Return the total number of real objects represented by the Table. This is useful when dealing with - prefixes/IP addresses/etc., where some table rows may represent available address space. - """ - if not hasattr(self, '_objects_count'): - self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) - return self._objects_count - - -# -# Table columns -# class ToggleColumn(tables.CheckBoxColumn): """ @@ -557,34 +440,3 @@ class MarkdownColumn(tables.TemplateColumn): def value(self, value): return value - - -# -# Pagination -# - -def paginate_table(table, request): - """ - Paginate a table given a request context. - """ - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_email(value): - if value is None: - return None - return f"mailto:{value}" - - -def linkify_phone(value): - if value is None: - return None - return f"tel:{value}" diff --git a/netbox/utilities/tables/tables.py b/netbox/utilities/tables/tables.py new file mode 100644 index 000000000..a18800595 --- /dev/null +++ b/netbox/utilities/tables/tables.py @@ -0,0 +1,138 @@ +import django_tables2 as tables +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist +from django.db.models.fields.related import RelatedField +from django_tables2.data import TableQuerysetData + +from extras.models import CustomField, CustomLink +from . import columns + +__all__ = ( + 'BaseTable', +) + + +class BaseTable(tables.Table): + """ + Default table for object lists + + :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. + """ + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + + class Meta: + attrs = { + 'class': 'table table-hover object-list', + } + + def __init__(self, *args, user=None, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field columns + obj_type = ContentType.objects.get_for_model(self._meta.model) + cf_columns = [ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type) + ] + cl_columns = [ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type) + ] + extra_columns.extend([*cf_columns, *cl_columns]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) + + # Set default empty_text if none was provided + if self.empty_text is None: + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" + + # Hide non-default columns + default_columns = getattr(self.Meta, 'default_columns', list()) + if default_columns: + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) + + # Apply custom column ordering for user + if user is not None and not isinstance(user, AnonymousUser): + selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + if selected_columns: + + # Show only persistent or selected columns + for name, column in self.columns.items(): + if name in ['pk', 'actions', *selected_columns]: + self.columns.show(name) + else: + self.columns.hide(name) + + # Rearrange the sequence to list selected columns first, followed by all remaining columns + # TODO: There's probably a more clever way to accomplish this + self.sequence = [ + *[c for c in selected_columns if c in self.columns.names()], + *[c for c in self.columns.names() if c not in selected_columns] + ] + + # PK column should always come first + if 'pk' in self.sequence: + self.sequence.remove('pk') + self.sequence.insert(0, 'pk') + + # Actions column should always come last + if 'actions' in self.sequence: + self.sequence.remove('actions') + self.sequence.append('actions') + + # Dynamically update the table's QuerySet to ensure related fields are pre-fetched + if isinstance(self.data, TableQuerysetData): + + prefetch_fields = [] + for column in self.columns: + if column.visible: + model = getattr(self.Meta, 'model') + accessor = column.accessor + prefetch_path = [] + for field_name in accessor.split(accessor.SEPARATOR): + try: + field = model._meta.get_field(field_name) + except FieldDoesNotExist: + break + if isinstance(field, RelatedField): + # Follow ForeignKeys to the related model + prefetch_path.append(field_name) + model = field.remote_field.model + elif isinstance(field, GenericForeignKey): + # Can't prefetch beyond a GenericForeignKey + prefetch_path.append(field_name) + break + if prefetch_path: + prefetch_fields.append('__'.join(prefetch_path)) + self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + + def _get_columns(self, visible=True): + columns = [] + for name, column in self.columns.items(): + if column.visible == visible and name not in ['pk', 'actions']: + columns.append((name, column.verbose_name)) + return columns + + @property + def available_columns(self): + return self._get_columns(visible=False) + + @property + def selected_columns(self): + return self._get_columns(visible=True) + + @property + def objects_count(self): + """ + Return the total number of real objects represented by the Table. This is useful when dealing with + prefixes/IP addresses/etc., where some table rows may represent available address space. + """ + if not hasattr(self, '_objects_count'): + self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) + return self._objects_count