Skip to content

Commit c158bfa

Browse files
committed
Sort interface templates of device type
Use the new hook to re-sort the interface template list. It is only done when required to preserve the original order and prevent any unneeded computation. But even when sorting, it tries its best to preserve as the original order as much as possible. The sorting is still done before any validation of the individual elements, so needs to be able to work with invalid/incomplete/malformed data. The only guarantee is that we get a list (of something). It builds a simple dependency graph, uses Kahn's algorithm to create a topological sorting of it, and applies that to the interface template list.
1 parent fa7b4d8 commit c158bfa

File tree

2 files changed

+163
-6
lines changed

2 files changed

+163
-6
lines changed

netbox/dcim/tests/test_views.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,11 +1063,32 @@ def test_import_interfacebridge(self):
10631063
slug: test-4000
10641064
u_height: 1
10651065
interfaces:
1066-
- name: Bridge
1066+
- name: Standalone 1
1067+
type: 1000base-t
1068+
- name: Bridge Interface 2
10671069
type: 1000base-t
1070+
bridge: Bridge
10681071
- name: Bridge Interface 1
10691072
type: 1000base-t
10701073
bridge: Bridge
1074+
- name: Standalone 2
1075+
type: 1000base-t
1076+
- name: Sub-Bridge Interface 2
1077+
type: 1000base-t
1078+
bridge: Sub-Bridge
1079+
- name: Bridge
1080+
type: 1000base-t
1081+
- name: Sub-Bridge
1082+
type: 1000base-t
1083+
bridge: Bridge
1084+
- name: Bridge Interface 3
1085+
type: 1000base-t
1086+
bridge: Bridge
1087+
- name: Sub-Bridge Interface 1
1088+
type: 1000base-t
1089+
bridge: Sub-Bridge
1090+
- name: Standalone 3
1091+
type: 1000base-t
10711092
"""
10721093

10731094
# Add all required permissions to the test user
@@ -1096,13 +1117,74 @@ def test_import_interfacebridge(self):
10961117
self.assertContains(response, "Imported 1 device types")
10971118

10981119
device_type = DeviceType.objects.get(model='TEST-4000')
1099-
self.assertEqual(device_type.interfacetemplates.count(), 2)
1120+
self.assertEqual(device_type.interfacetemplates.count(), 10)
11001121

11011122
interfaces = InterfaceTemplate.objects.all().order_by('id')
1102-
self.assertEqual(interfaces[0].name, 'Bridge')
1123+
self.assertEqual(interfaces[0].name, 'Standalone 1')
11031124
self.assertIsNone(interfaces[0].bridge)
1104-
self.assertEqual(interfaces[1].name, 'Bridge Interface 1')
1105-
self.assertEqual(interfaces[1].bridge.name, "Bridge")
1125+
self.assertEqual(interfaces[1].name, 'Standalone 2')
1126+
self.assertIsNone(interfaces[1].bridge)
1127+
self.assertEqual(interfaces[2].name, 'Bridge')
1128+
self.assertIsNone(interfaces[2].bridge)
1129+
self.assertEqual(interfaces[3].name, 'Standalone 3')
1130+
self.assertIsNone(interfaces[3].bridge)
1131+
self.assertEqual(interfaces[4].name, 'Bridge Interface 2')
1132+
self.assertEqual(interfaces[4].bridge.name, "Bridge")
1133+
self.assertEqual(interfaces[5].name, 'Bridge Interface 1')
1134+
self.assertEqual(interfaces[5].bridge.name, "Bridge")
1135+
self.assertEqual(interfaces[6].name, 'Sub-Bridge')
1136+
self.assertEqual(interfaces[6].bridge.name, "Bridge")
1137+
self.assertEqual(interfaces[7].name, 'Bridge Interface 3')
1138+
self.assertEqual(interfaces[7].bridge.name, "Bridge")
1139+
self.assertEqual(interfaces[8].name, 'Sub-Bridge Interface 2')
1140+
self.assertEqual(interfaces[8].bridge.name, "Sub-Bridge")
1141+
self.assertEqual(interfaces[9].name, 'Sub-Bridge Interface 1')
1142+
self.assertEqual(interfaces[9].bridge.name, "Sub-Bridge")
1143+
1144+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
1145+
def test_import_interfacebridge_cycle(self):
1146+
IMPORT_DATA = """
1147+
manufacturer: Manufacturer 1
1148+
model: TEST-5000
1149+
slug: test-5000
1150+
u_height: 1
1151+
interfaces:
1152+
- name: Interface 1
1153+
type: 1000base-t
1154+
bridge: Interface 2
1155+
- name: Interface 2
1156+
type: 1000base-t
1157+
bridge: Interface 3
1158+
- name: Interface 3
1159+
type: 1000base-t
1160+
bridge: Interface 1
1161+
"""
1162+
1163+
# Add all required permissions to the test user
1164+
self.add_permissions(
1165+
'dcim.view_devicetype',
1166+
'dcim.add_devicetype',
1167+
'dcim.add_consoleporttemplate',
1168+
'dcim.add_consoleserverporttemplate',
1169+
'dcim.add_powerporttemplate',
1170+
'dcim.add_poweroutlettemplate',
1171+
'dcim.add_interfacetemplate',
1172+
'dcim.add_frontporttemplate',
1173+
'dcim.add_rearporttemplate',
1174+
'dcim.add_modulebaytemplate',
1175+
'dcim.add_devicebaytemplate',
1176+
'dcim.add_inventoryitemtemplate',
1177+
)
1178+
1179+
form_data = {
1180+
'data': IMPORT_DATA,
1181+
'format': 'yaml'
1182+
}
1183+
1184+
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
1185+
self.assertHttpStatus(response, 200)
1186+
self.assertContains(response, "interfaces: Dependency cycle detected in subset "
1187+
"[Interface 1, Interface 2, Interface 3]")
11061188

11071189
def test_export_objects(self):
11081190
url = reverse('dcim:devicetype_list')

netbox/dcim/views.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from collections import deque
2+
13
from django.contrib import messages
24
from django.contrib.contenttypes.models import ContentType
35
from django.core.paginator import EmptyPage, PageNotAnInteger
46
from django.db import router, transaction
57
from django.db.models import Prefetch
6-
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
8+
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, ValidationError, modelformset_factory
79
from django.shortcuts import get_object_or_404, redirect, render
810
from django.urls import reverse
911
from django.utils.html import escape
@@ -1283,6 +1285,79 @@ class DeviceTypeImportView(generic.BulkImportView):
12831285
'inventory-items': forms.InventoryItemTemplateImportForm,
12841286
}
12851287

1288+
def _sort_interfaces(self, interfaces):
1289+
"""Sort the given enumerated interface list to satisfy any dependencies."""
1290+
if not interfaces:
1291+
return
1292+
1293+
# build the dependency graph
1294+
all_interface_names = set()
1295+
sorting_required = False
1296+
required_by = dict() # ifname to list of dependant subinterfaces # TODO replace list with ordered set
1297+
requires = dict() # ifname to set of depended-on superinterfaces
1298+
for idx, interface in interfaces:
1299+
if not isinstance(interface, dict): # TODO isinstance(MutableMapping)?
1300+
# not a dict, will fail validation anyway
1301+
# but still sort if required, to prevent false "bridge is invalid" errors on any other valid interfaces
1302+
continue
1303+
if not interface.get('name'):
1304+
# no interface name, will fail validation anyway
1305+
continue
1306+
1307+
ifname = interface['name']
1308+
all_interface_names.add(ifname)
1309+
1310+
requirements = set()
1311+
if bridge := interface.get('bridge'):
1312+
requirements.add(bridge)
1313+
1314+
requires[ifname] = requirements
1315+
for requirement in list(requirements):
1316+
required_by.setdefault(requirement, list()).append(ifname)
1317+
1318+
# if we haven't seen all requirements yet, sorting is needed
1319+
sorting_required |= not requirements.issubset(all_interface_names)
1320+
1321+
if not sorting_required:
1322+
return
1323+
1324+
# use Kahn's algorithm to build a topological sorting
1325+
workqueue = deque(ifname for ifname, requirements in requires.items() if not requirements)
1326+
ifname_ordering = list()
1327+
while workqueue:
1328+
ifname = workqueue.popleft()
1329+
ifname_ordering.append(ifname)
1330+
1331+
for dependant in list(required_by.get(ifname, list())):
1332+
requires[dependant].remove(ifname)
1333+
if not requires[dependant]:
1334+
workqueue.append(dependant)
1335+
1336+
if len(ifname_ordering) != len(set(ifname_ordering)):
1337+
# should never happen
1338+
raise ValueError("Interface ordering contains duplicates")
1339+
1340+
unsatisfied_requirements = list(ifname for ifname, deps in requires.items() if deps)
1341+
if unsatisfied_requirements:
1342+
raise ValidationError(
1343+
_("Dependency cycle detected in subset [%(interfaces)s]"),
1344+
params={"interfaces": ", ".join(unsatisfied_requirements)},
1345+
)
1346+
1347+
# apply the topological sorting to the actual list
1348+
def get_sort_key(interface):
1349+
try:
1350+
return ifname_ordering.index(interface['name'])
1351+
except Exception:
1352+
# Everything broken/invalid goes to the beginning, so validation fails fast
1353+
return -1
1354+
1355+
interfaces.sort(key=lambda entry_tuple: get_sort_key(entry_tuple[1]))
1356+
1357+
def prep_related_object_list(self, field_name, enumerated_list):
1358+
if field_name == 'interfaces':
1359+
self._sort_interfaces(enumerated_list)
1360+
12861361
def prep_related_object_data(self, parent, data):
12871362
data.update({'device_type': parent})
12881363
return data

0 commit comments

Comments
 (0)