Compare commits

...

3 Commits

Author SHA1 Message Date
Jeremy Stretch
372eef2de9 Remove user kwargs from table instantiations 2026-02-18 16:14:22 -05:00
Jeremy Stretch
38c3e73dd2 Add a test for dynamic table prefetching 2026-02-18 09:34:22 -05:00
Jeremy Stretch
64735d587c Closes #21459: Avoid prefetching data for hidden table columns 2026-02-17 17:12:17 -05:00
7 changed files with 91 additions and 52 deletions

View File

@@ -682,7 +682,7 @@ class PluginListView(BasePluginView):
plugins = [plugin for plugin in plugins if not plugin.hidden]
table = CatalogPluginTable(plugins, user=request.user)
table = CatalogPluginTable(plugins)
table.configure(request)
# If this is an HTMX request, return only the rendered table HTML
@@ -705,7 +705,7 @@ class PluginView(BasePluginView):
raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name]
table = PluginVersionTable(plugin.release_recent_history, user=request.user)
table = PluginVersionTable(plugin.release_recent_history)
table.configure(request)
return render(request, 'core/plugin.html', {

View File

@@ -1576,11 +1576,7 @@ class ScriptJobsView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
jobs_table = ScriptJobTable(
data=script.jobs.all(),
orderable=False,
user=request.user
)
jobs_table = ScriptJobTable(data=script.jobs.all(), orderable=False)
jobs_table.configure(request)
return render(request, 'extras/script/jobs.html', {
@@ -1626,7 +1622,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
}
data.append(result)
table = ScriptResultsTable(data, user=request.user)
table = ScriptResultsTable(data)
table.configure(request)
else:
# for legacy reports
@@ -1650,7 +1646,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
}
data.append(result)
table = ReportResultsTable(data, user=request.user)
table = ReportResultsTable(data)
table.configure(request)
return table

View File

@@ -52,43 +52,14 @@ class BaseTable(tables.Table):
'class': 'table table-hover object-list',
}
def __init__(self, *args, user=None, **kwargs):
# TODO: Remove user kwarg in NetBox v4.7
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set default empty_text if none was provided
if self.empty_text is None:
self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)
# 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
if accessor.startswith('custom_field_data__'):
# Ignore custom field references
continue
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, ManyToOneRel)):
# 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(*prefetch_fields)
def _get_columns(self, visible=True):
columns = []
for name, column in self.columns.items():
@@ -144,6 +115,41 @@ class BaseTable(tables.Table):
self.sequence.remove('actions')
self.sequence.append('actions')
def _apply_prefetching(self):
"""
Dynamically update the table's QuerySet to ensure related fields are pre-fetched
"""
if not isinstance(self.data, TableQuerysetData):
return
prefetch_fields = []
for column in self.columns:
if not column.visible:
# Skip hidden columns
continue
model = getattr(self.Meta, 'model') # Must be called *after* resolving columns
accessor = column.accessor
if accessor.startswith('custom_field_data__'):
# Ignore custom field references
continue
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, ManyToOneRel)):
# 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(*prefetch_fields)
def configure(self, request):
"""
Configure the table for a specific request context. This performs pagination and records
@@ -178,6 +184,7 @@ class BaseTable(tables.Table):
columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
self._set_columns(columns)
self._apply_prefetching()
if ordering is not None:
self.order_by = ordering

View File

@@ -1,9 +1,50 @@
from django.template import Context, Template
from django.test import TestCase
from django.test import RequestFactory, TestCase
from dcim.models import Site
from dcim.models import Device, Site
from dcim.tables import DeviceTable
from netbox.tables import NetBoxTable, columns
from utilities.testing import create_tags
from utilities.testing import create_tags, create_test_device, create_test_user
class BaseTableTest(TestCase):
@classmethod
def setUpTestData(cls):
create_test_device('Test Device 1')
cls.user = create_test_user('testuser')
def test_prefetch_visible_columns(self):
"""
Verify that the table queryset's prefetch_related lookups correspond to the user's
visible column preferences. Columns referencing related fields should only be
prefetched when those columns are visible.
"""
request = RequestFactory().get('/')
request.user = self.user
# Scenario 1: 'rack' (simple FK) and 'region' (nested accessor: site__region) are visible
self.user.config.set(
'tables.DeviceTable.columns',
['name', 'status', 'site', 'rack', 'region'],
commit=True,
)
table = DeviceTable(Device.objects.all())
table.configure(request)
prefetch_lookups = table.data.data._prefetch_related_lookups
self.assertIn('rack', prefetch_lookups)
self.assertIn('site__region', prefetch_lookups)
# Scenario 2: Local fields only; no prefetching
self.user.config.set(
'tables.DeviceTable.columns',
['name', 'status', 'description'],
commit=True,
)
table = DeviceTable(Device.objects.all())
table.configure(request)
prefetch_lookups = table.data.data._prefetch_related_lookups
self.assertEqual(prefetch_lookups, tuple())
class TagColumnTable(NetBoxTable):

View File

@@ -68,7 +68,6 @@ class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
objectchanges_table = ObjectChangeTable(
data=objectchanges,
orderable=False,
user=request.user
)
objectchanges_table.configure(request)
@@ -152,7 +151,7 @@ class ObjectJournalView(ConditionalLoginRequiredMixin, View):
assigned_object_type=content_type,
assigned_object_id=obj.pk
)
journalentry_table = JournalEntryTable(journalentries, user=request.user)
journalentry_table = JournalEntryTable(journalentries)
journalentry_table.configure(request)
journalentry_table.columns.hide('assigned_object_type')
journalentry_table.columns.hide('assigned_object')
@@ -219,11 +218,7 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
# Gather all Jobs for this object
jobs = self.get_jobs(obj)
jobs_table = JobTable(
data=jobs,
orderable=False,
user=request.user
)
jobs_table = JobTable(data=jobs, orderable=False)
jobs_table.configure(request)
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,

View File

@@ -92,7 +92,7 @@ class TableMixin:
request.user.config.set(f'tables.{table}.columns', tableconfig.columns)
request.user.config.set(f'tables.{table}.ordering', tableconfig.ordering, commit=True)
table = self.table(data, user=request.user)
table = self.table(data)
if 'pk' in table.base_columns and bulk_actions:
table.columns.show('pk')
table.configure(request)

View File

@@ -109,7 +109,7 @@ class WirelessLANView(generic.ObjectView):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
interfaces_table.configure(request)
return {