netbox-device-type-sync 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_device_type_sync/__init__.py +23 -0
- netbox_device_type_sync/apply.py +402 -0
- netbox_device_type_sync/components.py +641 -0
- netbox_device_type_sync/diff.py +248 -0
- netbox_device_type_sync/forms.py +35 -0
- netbox_device_type_sync/templates/netbox_device_type_sync/device_sync.html +239 -0
- netbox_device_type_sync/tests/__init__.py +0 -0
- netbox_device_type_sync/tests/test_apply.py +427 -0
- netbox_device_type_sync/tests/test_coverage.py +174 -0
- netbox_device_type_sync/tests/test_diff.py +313 -0
- netbox_device_type_sync/tests/test_load.py +15 -0
- netbox_device_type_sync/tests/test_ui.py +111 -0
- netbox_device_type_sync/tests/test_views.py +123 -0
- netbox_device_type_sync/tests/utils.py +41 -0
- netbox_device_type_sync/ui.py +96 -0
- netbox_device_type_sync/urls.py +7 -0
- netbox_device_type_sync/version.py +1 -0
- netbox_device_type_sync/views.py +109 -0
- netbox_device_type_sync-1.0.0.dist-info/METADATA +267 -0
- netbox_device_type_sync-1.0.0.dist-info/RECORD +23 -0
- netbox_device_type_sync-1.0.0.dist-info/WHEEL +5 -0
- netbox_device_type_sync-1.0.0.dist-info/licenses/LICENSE +21 -0
- netbox_device_type_sync-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
from ipam.models import VLAN, IPAddress
|
|
3
|
+
|
|
4
|
+
from dcim.choices import (
|
|
5
|
+
DeviceAirflowChoices,
|
|
6
|
+
InterfaceModeChoices,
|
|
7
|
+
InterfaceTypeChoices,
|
|
8
|
+
PortTypeChoices,
|
|
9
|
+
)
|
|
10
|
+
from dcim.models import (
|
|
11
|
+
Cable,
|
|
12
|
+
FrontPortTemplate,
|
|
13
|
+
Interface,
|
|
14
|
+
InterfaceTemplate,
|
|
15
|
+
InventoryItem,
|
|
16
|
+
InventoryItemTemplate,
|
|
17
|
+
Module,
|
|
18
|
+
ModuleBayTemplate,
|
|
19
|
+
ModuleBayType,
|
|
20
|
+
ModuleType,
|
|
21
|
+
Platform,
|
|
22
|
+
PortTemplateMapping,
|
|
23
|
+
PowerOutletTemplate,
|
|
24
|
+
PowerPortTemplate,
|
|
25
|
+
RearPortTemplate,
|
|
26
|
+
)
|
|
27
|
+
from utilities.testing import TestCase as NetBoxTestCase
|
|
28
|
+
|
|
29
|
+
from netbox_device_type_sync.apply import SyncError, apply_plan
|
|
30
|
+
from netbox_device_type_sync.diff import build_plan
|
|
31
|
+
from netbox_device_type_sync.tests.utils import SyncTestMixin
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sync(device, selected=(), user=None):
|
|
35
|
+
return apply_plan(build_plan(device), selected, user=user)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApplyTestCase(SyncTestMixin, TestCase):
|
|
39
|
+
def setUp(self):
|
|
40
|
+
self.type_1 = self.create_device_type('Type 1')
|
|
41
|
+
InterfaceTemplate.objects.create(
|
|
42
|
+
device_type=self.type_1, name='eth0', type='1000base-t'
|
|
43
|
+
)
|
|
44
|
+
InterfaceTemplate.objects.create(
|
|
45
|
+
device_type=self.type_1, name='eth1', type='1000base-t'
|
|
46
|
+
)
|
|
47
|
+
self.type_2 = self.create_device_type('Type 2')
|
|
48
|
+
InterfaceTemplate.objects.create(
|
|
49
|
+
device_type=self.type_2,
|
|
50
|
+
name='eth0',
|
|
51
|
+
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
52
|
+
description='Uplink',
|
|
53
|
+
)
|
|
54
|
+
InterfaceTemplate.objects.create(
|
|
55
|
+
device_type=self.type_2, name='eth2', type='1000base-t'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def test_replacement_keeps_cables_ip_addresses_and_vlans(self):
|
|
59
|
+
device = self.create_device(self.type_1)
|
|
60
|
+
peer = self.create_device(self.type_1, name='Peer')
|
|
61
|
+
eth0 = device.interfaces.get(name='eth0')
|
|
62
|
+
Cable(
|
|
63
|
+
a_terminations=[eth0], b_terminations=[peer.interfaces.get(name='eth0')]
|
|
64
|
+
).save()
|
|
65
|
+
ip = IPAddress.objects.create(address='192.0.2.1/24', assigned_object=eth0)
|
|
66
|
+
vlan = VLAN.objects.create(vid=10, name='VLAN 10')
|
|
67
|
+
eth0.refresh_from_db()
|
|
68
|
+
eth0.mode = InterfaceModeChoices.MODE_ACCESS
|
|
69
|
+
eth0.untagged_vlan = vlan
|
|
70
|
+
eth0.save()
|
|
71
|
+
device = self.change_device_type(device, self.type_2)
|
|
72
|
+
|
|
73
|
+
result = sync(device, selected=['interface.type'])
|
|
74
|
+
|
|
75
|
+
eth0 = device.interfaces.get(name='eth0')
|
|
76
|
+
self.assertEqual(eth0.type, InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
|
|
77
|
+
self.assertEqual(eth0.description, '') # not selected
|
|
78
|
+
self.assertIsNotNone(eth0.cable)
|
|
79
|
+
self.assertEqual(eth0.untagged_vlan, vlan)
|
|
80
|
+
self.assertEqual(eth0.mode, InterfaceModeChoices.MODE_ACCESS)
|
|
81
|
+
ip.refresh_from_db()
|
|
82
|
+
self.assertEqual(ip.assigned_object, eth0)
|
|
83
|
+
# eth1 exists only on the device: kept as it is
|
|
84
|
+
self.assertEqual(device.interfaces.get(name='eth1').type, '1000base-t')
|
|
85
|
+
# eth2 is created without a description, like NetBox does
|
|
86
|
+
self.assertTrue(device.interfaces.filter(name='eth2').exists())
|
|
87
|
+
self.assertEqual(result.created['interface'], 1)
|
|
88
|
+
self.assertEqual(result.updated['interface'], 1)
|
|
89
|
+
self.assertEqual(build_plan(device).create_count, 0)
|
|
90
|
+
|
|
91
|
+
def test_unselected_properties_are_kept(self):
|
|
92
|
+
device = self.change_device_type(self.create_device(self.type_1), self.type_2)
|
|
93
|
+
|
|
94
|
+
sync(device, selected=[])
|
|
95
|
+
|
|
96
|
+
self.assertEqual(device.interfaces.get(name='eth0').type, '1000base-t')
|
|
97
|
+
self.assertTrue(device.interfaces.filter(name='eth2').exists())
|
|
98
|
+
|
|
99
|
+
def test_selected_description(self):
|
|
100
|
+
device = self.change_device_type(self.create_device(self.type_1), self.type_2)
|
|
101
|
+
|
|
102
|
+
sync(device, selected=['interface.description'])
|
|
103
|
+
|
|
104
|
+
eth0 = device.interfaces.get(name='eth0')
|
|
105
|
+
self.assertEqual(eth0.description, 'Uplink')
|
|
106
|
+
self.assertEqual(eth0.type, '1000base-t')
|
|
107
|
+
|
|
108
|
+
def test_validation_error_rolls_back_everything(self):
|
|
109
|
+
device = self.create_device(self.type_1)
|
|
110
|
+
peer = self.create_device(self.type_1, name='Peer')
|
|
111
|
+
Cable(
|
|
112
|
+
a_terminations=[device.interfaces.get(name='eth0')],
|
|
113
|
+
b_terminations=[peer.interfaces.get(name='eth0')],
|
|
114
|
+
).save()
|
|
115
|
+
self.type_2.interfacetemplates.filter(name='eth0').update(type='virtual')
|
|
116
|
+
device = self.change_device_type(device, self.type_2)
|
|
117
|
+
|
|
118
|
+
with self.assertRaises(SyncError) as cm:
|
|
119
|
+
sync(device, selected=['interface.type', 'interface.description'])
|
|
120
|
+
|
|
121
|
+
self.assertEqual(len(cm.exception.errors), 1)
|
|
122
|
+
self.assertIn('Interface eth0', cm.exception.errors[0])
|
|
123
|
+
self.assertIn('cable', cm.exception.errors[0])
|
|
124
|
+
# Nothing was changed: eth2 was not created, eth0 kept its values
|
|
125
|
+
self.assertFalse(device.interfaces.filter(name='eth2').exists())
|
|
126
|
+
self.assertEqual(device.interfaces.get(name='eth0').description, '')
|
|
127
|
+
|
|
128
|
+
def test_device_fields(self):
|
|
129
|
+
platform = Platform.objects.create(name='Platform 1', slug='platform-1')
|
|
130
|
+
self.type_2.default_platform = platform
|
|
131
|
+
self.type_2.airflow = DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR
|
|
132
|
+
self.type_2.save()
|
|
133
|
+
device = self.change_device_type(self.create_device(self.type_1), self.type_2)
|
|
134
|
+
|
|
135
|
+
result = sync(device, selected=['device.platform'])
|
|
136
|
+
|
|
137
|
+
device.refresh_from_db()
|
|
138
|
+
self.assertEqual(device.platform, platform)
|
|
139
|
+
self.assertIsNone(device.airflow) # not selected
|
|
140
|
+
self.assertEqual(result.device_fields, ['Platform'])
|
|
141
|
+
|
|
142
|
+
def test_unknown_selection_keys_are_ignored(self):
|
|
143
|
+
device = self.change_device_type(self.create_device(self.type_1), self.type_2)
|
|
144
|
+
|
|
145
|
+
sync(device, selected=['interface.name', 'device.serial', 'nothing'])
|
|
146
|
+
|
|
147
|
+
self.assertEqual(device.interfaces.get(name='eth0').type, '1000base-t')
|
|
148
|
+
|
|
149
|
+
def test_module_conflicts_are_skipped(self):
|
|
150
|
+
bay = ModuleBayTemplate.objects.create(device_type=self.type_1, name='Bay 1')
|
|
151
|
+
module_type = ModuleType.objects.create(
|
|
152
|
+
manufacturer=self.manufacturer, model='Module 1'
|
|
153
|
+
)
|
|
154
|
+
InterfaceTemplate.objects.create(
|
|
155
|
+
module_type=module_type, name='eth2', type='1000base-t'
|
|
156
|
+
)
|
|
157
|
+
device = self.create_device(self.type_1)
|
|
158
|
+
Module.objects.create(
|
|
159
|
+
device=device,
|
|
160
|
+
module_bay=device.modulebays.get(name=bay.name),
|
|
161
|
+
module_type=module_type,
|
|
162
|
+
)
|
|
163
|
+
device = self.change_device_type(device, self.type_2)
|
|
164
|
+
|
|
165
|
+
result = sync(device)
|
|
166
|
+
|
|
167
|
+
self.assertEqual(result.created_count, 0)
|
|
168
|
+
self.assertIsNotNone(device.interfaces.get(name='eth2').module)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ApplyRelationsTestCase(SyncTestMixin, TestCase):
|
|
172
|
+
def test_new_interfaces_get_parent_and_bridge(self):
|
|
173
|
+
device_type = self.create_device_type('Type 1')
|
|
174
|
+
eth0 = InterfaceTemplate.objects.create(
|
|
175
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
176
|
+
)
|
|
177
|
+
device = self.create_device(device_type)
|
|
178
|
+
bridge = InterfaceTemplate.objects.create(
|
|
179
|
+
device_type=device_type, name='br0', type='bridge'
|
|
180
|
+
)
|
|
181
|
+
InterfaceTemplate.objects.create(
|
|
182
|
+
device_type=device_type,
|
|
183
|
+
name='eth0.10',
|
|
184
|
+
type='virtual',
|
|
185
|
+
parent=eth0,
|
|
186
|
+
bridge=bridge,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
sync(device)
|
|
190
|
+
|
|
191
|
+
child = device.interfaces.get(name='eth0.10')
|
|
192
|
+
self.assertEqual(child.parent.name, 'eth0')
|
|
193
|
+
self.assertEqual(child.bridge.name, 'br0')
|
|
194
|
+
|
|
195
|
+
def test_existing_interface_gets_new_parent(self):
|
|
196
|
+
device_type = self.create_device_type('Type 1')
|
|
197
|
+
sub = InterfaceTemplate.objects.create(
|
|
198
|
+
device_type=device_type, name='eth0.10', type='virtual'
|
|
199
|
+
)
|
|
200
|
+
device = self.create_device(device_type)
|
|
201
|
+
sub.parent = InterfaceTemplate.objects.create(
|
|
202
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
203
|
+
)
|
|
204
|
+
sub.save()
|
|
205
|
+
|
|
206
|
+
sync(device, selected=['interface.parent'])
|
|
207
|
+
|
|
208
|
+
self.assertEqual(device.interfaces.get(name='eth0.10').parent.name, 'eth0')
|
|
209
|
+
|
|
210
|
+
def test_channelized_interface(self):
|
|
211
|
+
device_type = self.create_device_type('Type 1')
|
|
212
|
+
parent = InterfaceTemplate.objects.create(
|
|
213
|
+
device_type=device_type,
|
|
214
|
+
name='eth0',
|
|
215
|
+
type=InterfaceTypeChoices.TYPE_100GE_QSFP28,
|
|
216
|
+
)
|
|
217
|
+
device = self.create_device(device_type)
|
|
218
|
+
parent.channels = 4
|
|
219
|
+
parent.save()
|
|
220
|
+
InterfaceTemplate.objects.create(
|
|
221
|
+
device_type=device_type,
|
|
222
|
+
name='eth0:1',
|
|
223
|
+
type=InterfaceTypeChoices.TYPE_CHANNEL,
|
|
224
|
+
parent=parent,
|
|
225
|
+
channel_id=1,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
sync(device, selected=['interface.channels'])
|
|
229
|
+
|
|
230
|
+
self.assertEqual(device.interfaces.get(name='eth0').channels, 4)
|
|
231
|
+
child = device.interfaces.get(name='eth0:1')
|
|
232
|
+
self.assertEqual(child.channel_id, 1)
|
|
233
|
+
self.assertEqual(child.parent.name, 'eth0')
|
|
234
|
+
|
|
235
|
+
def test_power_outlet_refers_to_new_power_port(self):
|
|
236
|
+
device_type = self.create_device_type('Type 1')
|
|
237
|
+
device = self.create_device(device_type)
|
|
238
|
+
power_port = PowerPortTemplate.objects.create(
|
|
239
|
+
device_type=device_type, name='PSU1'
|
|
240
|
+
)
|
|
241
|
+
PowerOutletTemplate.objects.create(
|
|
242
|
+
device_type=device_type, name='Out1', power_port=power_port
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
sync(device)
|
|
246
|
+
|
|
247
|
+
self.assertEqual(device.poweroutlets.get(name='Out1').power_port.name, 'PSU1')
|
|
248
|
+
|
|
249
|
+
def test_port_mappings(self):
|
|
250
|
+
device_type = self.create_device_type('Type 1')
|
|
251
|
+
rear = RearPortTemplate.objects.create(
|
|
252
|
+
device_type=device_type,
|
|
253
|
+
name='Rear',
|
|
254
|
+
type=PortTypeChoices.TYPE_LC,
|
|
255
|
+
positions=2,
|
|
256
|
+
)
|
|
257
|
+
front_1 = FrontPortTemplate.objects.create(
|
|
258
|
+
device_type=device_type, name='Front1', type=PortTypeChoices.TYPE_LC
|
|
259
|
+
)
|
|
260
|
+
mapping = PortTemplateMapping.objects.create(
|
|
261
|
+
front_port=front_1,
|
|
262
|
+
rear_port=rear,
|
|
263
|
+
front_port_position=1,
|
|
264
|
+
rear_port_position=1,
|
|
265
|
+
)
|
|
266
|
+
device = self.create_device(device_type)
|
|
267
|
+
# Re-point Front1 to position 2, and add Front2 on position 1
|
|
268
|
+
mapping.rear_port_position = 2
|
|
269
|
+
mapping.save()
|
|
270
|
+
front_2 = FrontPortTemplate.objects.create(
|
|
271
|
+
device_type=device_type, name='Front2', type=PortTypeChoices.TYPE_LC
|
|
272
|
+
)
|
|
273
|
+
PortTemplateMapping.objects.create(
|
|
274
|
+
front_port=front_2,
|
|
275
|
+
rear_port=rear,
|
|
276
|
+
front_port_position=1,
|
|
277
|
+
rear_port_position=1,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
sync(device, selected=['frontport.mappings'])
|
|
281
|
+
|
|
282
|
+
mappings = {
|
|
283
|
+
(
|
|
284
|
+
m.front_port.name,
|
|
285
|
+
m.front_port_position,
|
|
286
|
+
m.rear_port.name,
|
|
287
|
+
m.rear_port_position,
|
|
288
|
+
)
|
|
289
|
+
for m in device.port_mappings.all()
|
|
290
|
+
}
|
|
291
|
+
self.assertEqual(mappings, {('Front1', 1, 'Rear', 2), ('Front2', 1, 'Rear', 1)})
|
|
292
|
+
self.assertTrue(build_plan(device).is_empty)
|
|
293
|
+
|
|
294
|
+
def test_rear_port_positions_reduced_with_mappings(self):
|
|
295
|
+
device_type = self.create_device_type('Type 1')
|
|
296
|
+
rear = RearPortTemplate.objects.create(
|
|
297
|
+
device_type=device_type,
|
|
298
|
+
name='Rear',
|
|
299
|
+
type=PortTypeChoices.TYPE_LC,
|
|
300
|
+
positions=2,
|
|
301
|
+
)
|
|
302
|
+
fronts = [
|
|
303
|
+
FrontPortTemplate.objects.create(
|
|
304
|
+
device_type=device_type, name=f'Front{i}', type=PortTypeChoices.TYPE_LC
|
|
305
|
+
)
|
|
306
|
+
for i in (1, 2)
|
|
307
|
+
]
|
|
308
|
+
mappings = [
|
|
309
|
+
PortTemplateMapping.objects.create(
|
|
310
|
+
front_port=front,
|
|
311
|
+
rear_port=rear,
|
|
312
|
+
front_port_position=1,
|
|
313
|
+
rear_port_position=i,
|
|
314
|
+
)
|
|
315
|
+
for i, front in enumerate(fronts, start=1)
|
|
316
|
+
]
|
|
317
|
+
device = self.create_device(device_type)
|
|
318
|
+
mappings[1].delete()
|
|
319
|
+
rear.positions = 1
|
|
320
|
+
rear.save()
|
|
321
|
+
|
|
322
|
+
sync(device, selected=['rearport.positions', 'frontport.mappings'])
|
|
323
|
+
|
|
324
|
+
self.assertEqual(device.rearports.get(name='Rear').positions, 1)
|
|
325
|
+
self.assertEqual(device.port_mappings.count(), 1)
|
|
326
|
+
|
|
327
|
+
def test_inventory_items_are_created_parents_first(self):
|
|
328
|
+
device_type = self.create_device_type('Type 1')
|
|
329
|
+
device = self.create_device(device_type)
|
|
330
|
+
eth0 = InterfaceTemplate.objects.create(
|
|
331
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
332
|
+
)
|
|
333
|
+
chassis = InventoryItemTemplate.objects.create(
|
|
334
|
+
device_type=device_type, name='Chassis'
|
|
335
|
+
)
|
|
336
|
+
InventoryItemTemplate.objects.create(
|
|
337
|
+
device_type=device_type,
|
|
338
|
+
name='SFP',
|
|
339
|
+
parent=chassis,
|
|
340
|
+
component=eth0,
|
|
341
|
+
part_id='SFP-1',
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
sync(device)
|
|
345
|
+
|
|
346
|
+
sfp = device.inventoryitems.get(name='SFP')
|
|
347
|
+
self.assertEqual(sfp.parent.name, 'Chassis')
|
|
348
|
+
self.assertEqual(sfp.component, device.interfaces.get(name='eth0'))
|
|
349
|
+
self.assertEqual(sfp.part_id, 'SFP-1')
|
|
350
|
+
|
|
351
|
+
def test_inventory_item_fields_match_netbox(self):
|
|
352
|
+
# The plugin builds inventory items itself; compare with instantiate()
|
|
353
|
+
device_type = self.create_device_type('Type 1')
|
|
354
|
+
device = self.create_device(device_type)
|
|
355
|
+
template = InventoryItemTemplate.objects.create(
|
|
356
|
+
device_type=device_type, name='PSU', label='Power', part_id='PWR-1'
|
|
357
|
+
)
|
|
358
|
+
expected = template.instantiate(device=device)
|
|
359
|
+
|
|
360
|
+
sync(device)
|
|
361
|
+
|
|
362
|
+
item = InventoryItem.objects.get(device=device, name='PSU')
|
|
363
|
+
for field_name in (
|
|
364
|
+
'name',
|
|
365
|
+
'label',
|
|
366
|
+
'parent_id',
|
|
367
|
+
'component_type_id',
|
|
368
|
+
'component_id',
|
|
369
|
+
'role_id',
|
|
370
|
+
'manufacturer_id',
|
|
371
|
+
'part_id',
|
|
372
|
+
'description',
|
|
373
|
+
):
|
|
374
|
+
with self.subTest(field=field_name):
|
|
375
|
+
self.assertEqual(
|
|
376
|
+
getattr(item, field_name), getattr(expected, field_name)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def test_module_bay_types(self):
|
|
380
|
+
device_type = self.create_device_type('Type 1')
|
|
381
|
+
bay_1 = ModuleBayTemplate.objects.create(device_type=device_type, name='Bay 1')
|
|
382
|
+
device = self.create_device(device_type)
|
|
383
|
+
sfp = ModuleBayType.objects.create(name='SFP', slug='sfp')
|
|
384
|
+
bay_1.module_bay_types.add(sfp)
|
|
385
|
+
bay_2 = ModuleBayTemplate.objects.create(device_type=device_type, name='Bay 2')
|
|
386
|
+
bay_2.module_bay_types.add(sfp)
|
|
387
|
+
|
|
388
|
+
sync(device, selected=['modulebay.module_bay_types'])
|
|
389
|
+
|
|
390
|
+
for name in ('Bay 1', 'Bay 2'):
|
|
391
|
+
with self.subTest(bay=name):
|
|
392
|
+
bay = device.modulebays.get(name=name)
|
|
393
|
+
self.assertEqual(list(bay.module_bay_types.all()), [sfp])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ApplyPermissionsTestCase(SyncTestMixin, NetBoxTestCase):
|
|
397
|
+
def test_missing_add_permission_rolls_back(self):
|
|
398
|
+
device_type = self.create_device_type('Type 1')
|
|
399
|
+
InterfaceTemplate.objects.create(
|
|
400
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
401
|
+
)
|
|
402
|
+
device = self.create_device(device_type)
|
|
403
|
+
device_type.interfacetemplates.update(description='Uplink')
|
|
404
|
+
InterfaceTemplate.objects.create(
|
|
405
|
+
device_type=device_type, name='eth1', type='1000base-t'
|
|
406
|
+
)
|
|
407
|
+
self.add_permissions('dcim.change_interface')
|
|
408
|
+
|
|
409
|
+
with self.assertRaises(SyncError) as cm:
|
|
410
|
+
sync(device, selected=['interface.description'], user=self.user)
|
|
411
|
+
|
|
412
|
+
self.assertIn('Interface eth1', cm.exception.errors[0])
|
|
413
|
+
self.assertIn('permission', cm.exception.errors[0])
|
|
414
|
+
self.assertEqual(device.interfaces.get(name='eth0').description, '')
|
|
415
|
+
|
|
416
|
+
def test_with_permissions(self):
|
|
417
|
+
device_type = self.create_device_type('Type 1')
|
|
418
|
+
device = self.create_device(device_type)
|
|
419
|
+
InterfaceTemplate.objects.create(
|
|
420
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
421
|
+
)
|
|
422
|
+
self.add_permissions('dcim.add_interface')
|
|
423
|
+
|
|
424
|
+
result = sync(device, user=self.user)
|
|
425
|
+
|
|
426
|
+
self.assertEqual(result.created['interface'], 1)
|
|
427
|
+
self.assertTrue(Interface.objects.filter(device=device, name='eth0').exists())
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests that fail when a NetBox version adds something the plugin does not know
|
|
3
|
+
about: a component template model, a template field, a port mapping field or a
|
|
4
|
+
device type field. Update netbox_device_type_sync/components.py (and the
|
|
5
|
+
README) before changing these tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.test import SimpleTestCase, TestCase
|
|
10
|
+
|
|
11
|
+
from dcim.choices import CoolingMethodChoices, DeviceAirflowChoices
|
|
12
|
+
from dcim.models import DeviceType, Platform, PortTemplateMapping
|
|
13
|
+
from dcim.models.device_component_templates import ComponentTemplateModel
|
|
14
|
+
|
|
15
|
+
from netbox_device_type_sync.components import (
|
|
16
|
+
COMPONENT_KINDS,
|
|
17
|
+
DEVICE_FIELDS,
|
|
18
|
+
FieldProperty,
|
|
19
|
+
RelationProperty,
|
|
20
|
+
)
|
|
21
|
+
from netbox_device_type_sync.tests.utils import SyncTestMixin
|
|
22
|
+
|
|
23
|
+
# Template fields that identify a template rather than describe the component
|
|
24
|
+
IGNORED_TEMPLATE_FIELDS = {
|
|
25
|
+
'id',
|
|
26
|
+
'created',
|
|
27
|
+
'last_updated',
|
|
28
|
+
'name',
|
|
29
|
+
'_name',
|
|
30
|
+
'device_type',
|
|
31
|
+
'module_type',
|
|
32
|
+
'path',
|
|
33
|
+
'_abs_diameter',
|
|
34
|
+
'_abs_max_flow',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Every DeviceType field, by what the plugin does with it
|
|
38
|
+
DEVICE_TYPE_FIELDS = {
|
|
39
|
+
# Copied to the device only when it is created: synced by the plugin
|
|
40
|
+
'synced': {'default_platform', 'airflow', 'cooling_method'},
|
|
41
|
+
# Read from the device type, never stored on the device
|
|
42
|
+
'live': {
|
|
43
|
+
'manufacturer',
|
|
44
|
+
'model',
|
|
45
|
+
'slug',
|
|
46
|
+
'part_number',
|
|
47
|
+
'u_height',
|
|
48
|
+
'exclude_from_utilization',
|
|
49
|
+
'is_full_depth',
|
|
50
|
+
'subdevice_role',
|
|
51
|
+
'end_of_life',
|
|
52
|
+
'front_image',
|
|
53
|
+
'rear_image',
|
|
54
|
+
'weight',
|
|
55
|
+
'weight_unit',
|
|
56
|
+
'_abs_weight',
|
|
57
|
+
},
|
|
58
|
+
# Describe the device type itself; agreed not to sync
|
|
59
|
+
'not_synced': {
|
|
60
|
+
'id',
|
|
61
|
+
'created',
|
|
62
|
+
'last_updated',
|
|
63
|
+
'custom_field_data',
|
|
64
|
+
'owner',
|
|
65
|
+
'description',
|
|
66
|
+
'comments',
|
|
67
|
+
'tags',
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def model_fields(model):
|
|
73
|
+
return {f.name for f in model._meta.concrete_fields} | {
|
|
74
|
+
f.name for f in model._meta.many_to_many
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TemplateCoverageTestCase(SimpleTestCase):
|
|
79
|
+
def test_all_template_models_are_known(self):
|
|
80
|
+
template_models = {
|
|
81
|
+
model
|
|
82
|
+
for model in apps.get_app_config('dcim').get_models()
|
|
83
|
+
if issubclass(model, ComponentTemplateModel)
|
|
84
|
+
}
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
template_models, {kind.template_model for kind in COMPONENT_KINDS}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def test_all_template_fields_are_known(self):
|
|
90
|
+
for kind in COMPONENT_KINDS:
|
|
91
|
+
with self.subTest(kind=kind.key):
|
|
92
|
+
covered = set()
|
|
93
|
+
for prop in kind.properties:
|
|
94
|
+
covered.update(prop.template_fields)
|
|
95
|
+
self.assertEqual(
|
|
96
|
+
model_fields(kind.template_model) - IGNORED_TEMPLATE_FIELDS, covered
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def test_component_models_have_the_template_fields(self):
|
|
100
|
+
for kind in COMPONENT_KINDS:
|
|
101
|
+
component_fields = model_fields(kind.component_model)
|
|
102
|
+
for prop in kind.properties:
|
|
103
|
+
if isinstance(prop, (FieldProperty, RelationProperty)):
|
|
104
|
+
with self.subTest(kind=kind.key, property=prop.key):
|
|
105
|
+
self.assertTrue(set(prop.template_fields) <= component_fields)
|
|
106
|
+
|
|
107
|
+
def test_port_template_mapping_fields(self):
|
|
108
|
+
self.assertEqual(
|
|
109
|
+
model_fields(PortTemplateMapping),
|
|
110
|
+
{
|
|
111
|
+
'id',
|
|
112
|
+
'created',
|
|
113
|
+
'last_updated',
|
|
114
|
+
'device_type',
|
|
115
|
+
'module_type',
|
|
116
|
+
'front_port',
|
|
117
|
+
'front_port_position',
|
|
118
|
+
'rear_port',
|
|
119
|
+
'rear_port_position',
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def test_kind_order_matches_dependencies(self):
|
|
124
|
+
# Components that other components refer to must be processed first
|
|
125
|
+
keys = [kind.key for kind in COMPONENT_KINDS]
|
|
126
|
+
self.assertLess(keys.index('powerport'), keys.index('poweroutlet'))
|
|
127
|
+
self.assertLess(keys.index('coolingintake'), keys.index('coolingoutflow'))
|
|
128
|
+
self.assertLess(keys.index('rearport'), keys.index('frontport'))
|
|
129
|
+
self.assertEqual(keys[-1], 'inventoryitem')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DeviceTypeFieldsTestCase(SimpleTestCase):
|
|
133
|
+
def test_all_device_type_fields_are_classified(self):
|
|
134
|
+
fields = {f for f in model_fields(DeviceType) if not f.endswith('_count')}
|
|
135
|
+
classified = set().union(*DEVICE_TYPE_FIELDS.values())
|
|
136
|
+
self.assertEqual(fields, classified)
|
|
137
|
+
|
|
138
|
+
def test_device_fields_match_the_synced_fields(self):
|
|
139
|
+
self.assertEqual(
|
|
140
|
+
{f.source for f in DEVICE_FIELDS}, DEVICE_TYPE_FIELDS['synced']
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DeviceCreationTestCase(SyncTestMixin, TestCase):
|
|
145
|
+
"""
|
|
146
|
+
The plugin relies on NetBox copying these fields only when a device is
|
|
147
|
+
created, and never when its device type changes.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def test_fields_are_copied_only_on_creation(self):
|
|
151
|
+
platform_1 = Platform.objects.create(name='Platform 1', slug='platform-1')
|
|
152
|
+
platform_2 = Platform.objects.create(name='Platform 2', slug='platform-2')
|
|
153
|
+
type_1 = self.create_device_type(
|
|
154
|
+
'Type 1',
|
|
155
|
+
default_platform=platform_1,
|
|
156
|
+
airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR,
|
|
157
|
+
cooling_method=CoolingMethodChoices.METHOD_AIR,
|
|
158
|
+
)
|
|
159
|
+
type_2 = self.create_device_type(
|
|
160
|
+
'Type 2',
|
|
161
|
+
default_platform=platform_2,
|
|
162
|
+
airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT,
|
|
163
|
+
cooling_method=CoolingMethodChoices.METHOD_LIQUID,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
device = self.create_device(type_1)
|
|
167
|
+
self.assertEqual(device.platform, platform_1)
|
|
168
|
+
self.assertEqual(device.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
|
|
169
|
+
self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_AIR)
|
|
170
|
+
|
|
171
|
+
device = self.change_device_type(device, type_2)
|
|
172
|
+
self.assertEqual(device.platform, platform_1)
|
|
173
|
+
self.assertEqual(device.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
|
|
174
|
+
self.assertEqual(device.cooling_method, CoolingMethodChoices.METHOD_AIR)
|