Skip to content

Commit 57d3bfc

Browse files
Merge pull request #8073 from netbox-community/8057-htmx-tables
Closes #8057: Dynamic object tables using HTMX
2 parents b92e345 + 0e50c96 commit 57d3bfc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+666
-474
lines changed

netbox/dcim/views.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,41 +797,49 @@ def get_extra_context(self, request, instance):
797797
class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
798798
child_model = ConsolePortTemplate
799799
table = tables.ConsolePortTemplateTable
800+
filterset = filtersets.ConsolePortTemplateFilterSet
800801

801802

802803
class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
803804
child_model = ConsoleServerPortTemplate
804805
table = tables.ConsoleServerPortTemplateTable
806+
filterset = filtersets.ConsoleServerPortTemplateFilterSet
805807

806808

807809
class DeviceTypePowerPortsView(DeviceTypeComponentsView):
808810
child_model = PowerPortTemplate
809811
table = tables.PowerPortTemplateTable
812+
filterset = filtersets.PowerPortTemplateFilterSet
810813

811814

812815
class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
813816
child_model = PowerOutletTemplate
814817
table = tables.PowerOutletTemplateTable
818+
filterset = filtersets.PowerOutletTemplateFilterSet
815819

816820

817821
class DeviceTypeInterfacesView(DeviceTypeComponentsView):
818822
child_model = InterfaceTemplate
819823
table = tables.InterfaceTemplateTable
824+
filterset = filtersets.InterfaceTemplateFilterSet
820825

821826

822827
class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
823828
child_model = FrontPortTemplate
824829
table = tables.FrontPortTemplateTable
830+
filterset = filtersets.FrontPortTemplateFilterSet
825831

826832

827833
class DeviceTypeRearPortsView(DeviceTypeComponentsView):
828834
child_model = RearPortTemplate
829835
table = tables.RearPortTemplateTable
836+
filterset = filtersets.RearPortTemplateFilterSet
830837

831838

832839
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
833840
child_model = DeviceBayTemplate
834841
table = tables.DeviceBayTemplateTable
842+
filterset = filtersets.DeviceBayTemplateFilterSet
835843

836844

837845
class DeviceTypeEditView(generic.ObjectEditView):
@@ -1328,30 +1336,35 @@ def get_extra_context(self, request, instance):
13281336
class DeviceConsolePortsView(DeviceComponentsView):
13291337
child_model = ConsolePort
13301338
table = tables.DeviceConsolePortTable
1339+
filterset = filtersets.ConsolePortFilterSet
13311340
template_name = 'dcim/device/consoleports.html'
13321341

13331342

13341343
class DeviceConsoleServerPortsView(DeviceComponentsView):
13351344
child_model = ConsoleServerPort
13361345
table = tables.DeviceConsoleServerPortTable
1346+
filterset = filtersets.ConsoleServerPortFilterSet
13371347
template_name = 'dcim/device/consoleserverports.html'
13381348

13391349

13401350
class DevicePowerPortsView(DeviceComponentsView):
13411351
child_model = PowerPort
13421352
table = tables.DevicePowerPortTable
1353+
filterset = filtersets.PowerPortFilterSet
13431354
template_name = 'dcim/device/powerports.html'
13441355

13451356

13461357
class DevicePowerOutletsView(DeviceComponentsView):
13471358
child_model = PowerOutlet
13481359
table = tables.DevicePowerOutletTable
1360+
filterset = filtersets.PowerOutletFilterSet
13491361
template_name = 'dcim/device/poweroutlets.html'
13501362

13511363

13521364
class DeviceInterfacesView(DeviceComponentsView):
13531365
child_model = Interface
13541366
table = tables.DeviceInterfaceTable
1367+
filterset = filtersets.InterfaceFilterSet
13551368
template_name = 'dcim/device/interfaces.html'
13561369

13571370
def get_children(self, request, parent):
@@ -1364,24 +1377,28 @@ def get_children(self, request, parent):
13641377
class DeviceFrontPortsView(DeviceComponentsView):
13651378
child_model = FrontPort
13661379
table = tables.DeviceFrontPortTable
1380+
filterset = filtersets.FrontPortFilterSet
13671381
template_name = 'dcim/device/frontports.html'
13681382

13691383

13701384
class DeviceRearPortsView(DeviceComponentsView):
13711385
child_model = RearPort
13721386
table = tables.DeviceRearPortTable
1387+
filterset = filtersets.RearPortFilterSet
13731388
template_name = 'dcim/device/rearports.html'
13741389

13751390

13761391
class DeviceDeviceBaysView(DeviceComponentsView):
13771392
child_model = DeviceBay
13781393
table = tables.DeviceDeviceBayTable
1394+
filterset = filtersets.DeviceBayFilterSet
13791395
template_name = 'dcim/device/devicebays.html'
13801396

13811397

13821398
class DeviceInventoryView(DeviceComponentsView):
13831399
child_model = InventoryItem
13841400
table = tables.DeviceInventoryItemTable
1401+
filterset = filtersets.InventoryItemFilterSet
13851402
template_name = 'dcim/device/inventory.html'
13861403

13871404

netbox/ipam/models/ip.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ def family(self):
195195
return self.prefix.version
196196
return None
197197

198+
def get_child_prefixes(self):
199+
"""
200+
Return all Prefixes within this Aggregate
201+
"""
202+
return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
203+
198204
def get_utilization(self):
199205
"""
200206
Determine the prefix utilization of the aggregate and return it as a percentage.

netbox/ipam/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
6262
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
6363
path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
64+
path('aggregates/<int:pk>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
6465
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
6566
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
6667
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),

netbox/ipam/views.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
from django.contrib.contenttypes.models import ContentType
22
from django.db.models import Prefetch
33
from django.db.models.expressions import RawSQL
4-
from django.http import Http404
54
from django.shortcuts import get_object_or_404, redirect, render
65
from django.urls import reverse
76

7+
from dcim.filtersets import InterfaceFilterSet
88
from dcim.models import Device, Interface, Site
99
from dcim.tables import SiteTable
1010
from netbox.views import generic
1111
from utilities.tables import paginate_table
1212
from utilities.utils import count_related
13+
from virtualization.filtersets import VMInterfaceFilterSet
1314
from virtualization.models import VirtualMachine, VMInterface
1415
from . import filtersets, forms, tables
1516
from .constants import *
1617
from .models import *
1718
from .models import ASN
18-
from .utils import add_available_ipaddresses, add_requested_prefixes, add_available_vlans
19+
from .utils import add_requested_prefixes, add_available_vlans
1920

2021

2122
#
@@ -274,39 +275,32 @@ class AggregateListView(generic.ObjectListView):
274275
class AggregateView(generic.ObjectView):
275276
queryset = Aggregate.objects.all()
276277

277-
def get_extra_context(self, request, instance):
278-
# Find all child prefixes contained in this aggregate
279-
prefix_list = Prefix.objects.restrict(request.user, 'view').filter(
280-
prefix__net_contained_or_equal=str(instance.prefix)
281-
).prefetch_related(
282-
'site', 'role'
283-
).order_by(
284-
'prefix'
285-
)
286278

287-
# Return List of requested Prefixes
279+
class AggregatePrefixesView(generic.ObjectChildrenView):
280+
queryset = Aggregate.objects.all()
281+
child_model = Prefix
282+
table = tables.PrefixTable
283+
filterset = filtersets.PrefixFilterSet
284+
template_name = 'ipam/aggregate/prefixes.html'
285+
286+
def get_children(self, request, parent):
287+
return Prefix.objects.restrict(request.user, 'view').filter(
288+
prefix__net_contained_or_equal=str(parent.prefix)
289+
).prefetch_related('site', 'role', 'tenant', 'vlan')
290+
291+
def prep_table_data(self, request, queryset, parent):
292+
# Determine whether to show assigned prefixes, available prefixes, or both
288293
show_available = bool(request.GET.get('show_available', 'true') == 'true')
289294
show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
290-
child_prefixes = add_requested_prefixes(instance.prefix, prefix_list, show_available, show_assigned)
291295

292-
prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
293-
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
294-
prefix_table.columns.show('pk')
295-
paginate_table(prefix_table, request)
296-
297-
# Compile permissions list for rendering the object table
298-
permissions = {
299-
'add': request.user.has_perm('ipam.add_prefix'),
300-
'change': request.user.has_perm('ipam.change_prefix'),
301-
'delete': request.user.has_perm('ipam.delete_prefix'),
302-
}
296+
return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
303297

298+
def get_extra_context(self, request, instance):
304299
return {
305-
'prefix_table': prefix_table,
306-
'permissions': permissions,
307300
'bulk_querystring': f'within={instance.prefix}',
308-
'show_available': show_available,
309-
'show_assigned': show_assigned,
301+
'active_tab': 'prefixes',
302+
'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
303+
'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
310304
}
311305

312306

@@ -457,17 +451,18 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
457451
queryset = Prefix.objects.all()
458452
child_model = Prefix
459453
table = tables.PrefixTable
454+
filterset = filtersets.PrefixFilterSet
460455
template_name = 'ipam/prefix/prefixes.html'
461456

462457
def get_children(self, request, parent):
463-
child_prefixes = parent.get_child_prefixes().restrict(request.user, 'view')
458+
return parent.get_child_prefixes().restrict(request.user, 'view')
464459

465-
# Add available prefixes if requested
460+
def prep_table_data(self, request, queryset, parent):
461+
# Determine whether to show assigned prefixes, available prefixes, or both
466462
show_available = bool(request.GET.get('show_available', 'true') == 'true')
467463
show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
468-
child_prefixes = add_requested_prefixes(parent.prefix, child_prefixes, show_available, show_assigned)
469464

470-
return child_prefixes
465+
return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
471466

472467
def get_extra_context(self, request, instance):
473468
return {
@@ -483,6 +478,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
483478
queryset = Prefix.objects.all()
484479
child_model = IPRange
485480
table = tables.IPRangeTable
481+
filterset = filtersets.IPRangeFilterSet
486482
template_name = 'ipam/prefix/ip_ranges.html'
487483

488484
def get_children(self, request, parent):
@@ -499,6 +495,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
499495
queryset = Prefix.objects.all()
500496
child_model = IPAddress
501497
table = tables.IPAddressTable
498+
filterset = filtersets.IPAddressFilterSet
502499
template_name = 'ipam/prefix/ip_addresses.html'
503500

504501
def get_children(self, request, parent):
@@ -560,6 +557,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
560557
queryset = IPRange.objects.all()
561558
child_model = IPAddress
562559
table = tables.IPAddressTable
560+
filterset = filtersets.IPAddressFilterSet
563561
template_name = 'ipam/iprange/ip_addresses.html'
564562

565563
def get_children(self, request, parent):
@@ -959,6 +957,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
959957
queryset = VLAN.objects.all()
960958
child_model = Interface
961959
table = tables.VLANDevicesTable
960+
filterset = InterfaceFilterSet
962961
template_name = 'ipam/vlan/interfaces.html'
963962

964963
def get_children(self, request, parent):
@@ -974,6 +973,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
974973
queryset = VLAN.objects.all()
975974
child_model = VMInterface
976975
table = tables.VLANVirtualMachinesTable
976+
filterset = VMInterfaceFilterSet
977977
template_name = 'ipam/vlan/vminterfaces.html'
978978

979979
def get_children(self, request, parent):

netbox/netbox/views/generic.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from utilities.forms import (
2424
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
2525
)
26+
from utilities.htmx import is_htmx
2627
from utilities.permissions import get_permission_for_model
2728
from utilities.tables import paginate_table
2829
from utilities.utils import normalize_querydict, prepare_cloned_fields
@@ -83,35 +84,56 @@ class ObjectChildrenView(ObjectView):
8384
queryset = None
8485
child_model = None
8586
table = None
87+
filterset = None
8688
template_name = None
8789

8890
def get_children(self, request, parent):
8991
"""
90-
Return a QuerySet or iterable of child objects.
92+
Return a QuerySet of child objects.
9193
9294
request: The current request
9395
parent: The parent object
9496
"""
9597
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
9698

99+
def prep_table_data(self, request, queryset, parent):
100+
"""
101+
Provides a hook for subclassed views to modify data before initializing the table.
102+
103+
:param request: The current request
104+
:param queryset: The filtered queryset of child objects
105+
:param parent: The parent object
106+
"""
107+
return queryset
108+
97109
def get(self, request, *args, **kwargs):
98110
"""
99111
GET handler for rendering child objects.
100112
"""
101113
instance = get_object_or_404(self.queryset, **kwargs)
102114
child_objects = self.get_children(request, instance)
103115

116+
if self.filterset:
117+
child_objects = self.filterset(request.GET, child_objects).qs
118+
104119
permissions = {}
105120
for action in ('change', 'delete'):
106121
perm_name = get_permission_for_model(self.child_model, action)
107122
permissions[action] = request.user.has_perm(perm_name)
108123

109-
table = self.table(child_objects, user=request.user)
124+
table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
110125
# Determine whether to display bulk action checkboxes
111126
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
112127
table.columns.show('pk')
113128
paginate_table(table, request)
114129

130+
# If this is an HTMX request, return only the rendered table HTML
131+
if is_htmx(request):
132+
return render(request, 'htmx/table.html', {
133+
'object': instance,
134+
'table': table,
135+
})
136+
115137
return render(request, self.get_template_name(), {
116138
'object': instance,
117139
'table': table,
@@ -233,6 +255,12 @@ def get(self, request):
233255
table = self.get_table(request, permissions)
234256
paginate_table(table, request)
235257

258+
# If this is an HTMX request, return only the rendered table HTML
259+
if is_htmx(request):
260+
return render(request, 'htmx/table.html', {
261+
'table': table,
262+
})
263+
236264
context = {
237265
'content_type': content_type,
238266
'table': table,

netbox/project-static/dist/netbox-dark.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox-light.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox-print.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/dist/netbox.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)