Skip to content

Commit 9e7ced1

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 cd8590d commit 9e7ced1

File tree

2 files changed

+160
-6
lines changed

2 files changed

+160
-6
lines changed

netbox/dcim/tests/test_views.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,11 +1065,32 @@ def test_import_interfacebridge(self):
10651065
slug: test-4000
10661066
u_height: 1
10671067
interfaces:
1068-
- name: Bridge
1068+
- name: Standalone 1
1069+
type: 1000base-t
1070+
- name: Bridge Interface 2
10691071
type: 1000base-t
1072+
bridge: Bridge
10701073
- name: Bridge Interface 1
10711074
type: 1000base-t
10721075
bridge: Bridge
1076+
- name: Standalone 2
1077+
type: 1000base-t
1078+
- name: Sub-Bridge Interface 2
1079+
type: 1000base-t
1080+
bridge: Sub-Bridge
1081+
- name: Bridge
1082+
type: 1000base-t
1083+
- name: Sub-Bridge
1084+
type: 1000base-t
1085+
bridge: Bridge
1086+
- name: Bridge Interface 3
1087+
type: 1000base-t
1088+
bridge: Bridge
1089+
- name: Sub-Bridge Interface 1
1090+
type: 1000base-t
1091+
bridge: Sub-Bridge
1092+
- name: Standalone 3
1093+
type: 1000base-t
10731094
"""
10741095

10751096
# Add all required permissions to the test user
@@ -1098,13 +1119,73 @@ def test_import_interfacebridge(self):
10981119
self.assertContains(response, "Imported 1 device types")
10991120

11001121
device_type = DeviceType.objects.get(model='TEST-4000')
1101-
self.assertEqual(device_type.interfacetemplates.count(), 2)
1122+
self.assertEqual(device_type.interfacetemplates.count(), 10)
11021123

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

11091190
def test_export_objects(self):
11101191
url = reverse('dcim:devicetype_list')

netbox/dcim/views.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.core.paginator import EmptyPage, PageNotAnInteger
44
from django.db import router, transaction
55
from django.db.models import Prefetch
6-
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
6+
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, ValidationError, modelformset_factory
77
from django.shortcuts import get_object_or_404, redirect, render
88
from django.urls import reverse
99
from django.utils.html import escape
@@ -1283,6 +1283,79 @@ class DeviceTypeImportView(generic.BulkImportView):
12831283
'inventory-items': forms.InventoryItemTemplateImportForm,
12841284
}
12851285

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

0 commit comments

Comments
 (0)