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,313 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
|
|
3
|
+
from dcim.choices import (
|
|
4
|
+
DeviceAirflowChoices,
|
|
5
|
+
InterfacePoEModeChoices,
|
|
6
|
+
InterfaceTypeChoices,
|
|
7
|
+
PortTypeChoices,
|
|
8
|
+
PowerPortTypeChoices,
|
|
9
|
+
)
|
|
10
|
+
from dcim.models import (
|
|
11
|
+
ConsolePortTemplate,
|
|
12
|
+
CoolingIntakeTemplate,
|
|
13
|
+
FrontPortTemplate,
|
|
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
|
+
VirtualChassis,
|
|
27
|
+
)
|
|
28
|
+
from netbox.choices import DiameterUnitChoices
|
|
29
|
+
|
|
30
|
+
from netbox_device_type_sync.diff import build_plan
|
|
31
|
+
from netbox_device_type_sync.tests.utils import SyncTestMixin
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def kind_diff(plan, key):
|
|
35
|
+
return next(k for k in plan.kinds if k.kind.key == key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DiffTestCase(SyncTestMixin, TestCase):
|
|
39
|
+
def test_new_device_is_in_sync(self):
|
|
40
|
+
device_type = self.create_device_type('Type 1')
|
|
41
|
+
InterfaceTemplate.objects.create(
|
|
42
|
+
device_type=device_type, name='eth0', type='1000base-t'
|
|
43
|
+
)
|
|
44
|
+
ConsolePortTemplate.objects.create(
|
|
45
|
+
device_type=device_type, name='con0', type='rj-45'
|
|
46
|
+
)
|
|
47
|
+
device = self.create_device(device_type)
|
|
48
|
+
|
|
49
|
+
plan = build_plan(device)
|
|
50
|
+
|
|
51
|
+
self.assertTrue(plan.is_empty)
|
|
52
|
+
self.assertEqual(plan.selection_keys, [])
|
|
53
|
+
|
|
54
|
+
def test_replaced_device_type(self):
|
|
55
|
+
type_1 = self.create_device_type('Type 1')
|
|
56
|
+
InterfaceTemplate.objects.create(
|
|
57
|
+
device_type=type_1, name='eth0', type='1000base-t'
|
|
58
|
+
)
|
|
59
|
+
InterfaceTemplate.objects.create(
|
|
60
|
+
device_type=type_1, name='eth1', type='1000base-t'
|
|
61
|
+
)
|
|
62
|
+
type_2 = self.create_device_type('Type 2')
|
|
63
|
+
InterfaceTemplate.objects.create(
|
|
64
|
+
device_type=type_2,
|
|
65
|
+
name='eth0',
|
|
66
|
+
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
67
|
+
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
|
68
|
+
)
|
|
69
|
+
InterfaceTemplate.objects.create(
|
|
70
|
+
device_type=type_2, name='eth2', type='1000base-t'
|
|
71
|
+
)
|
|
72
|
+
device = self.change_device_type(self.create_device(type_1), type_2)
|
|
73
|
+
|
|
74
|
+
plan = build_plan(device)
|
|
75
|
+
interfaces = kind_diff(plan, 'interface')
|
|
76
|
+
|
|
77
|
+
self.assertEqual([c.name for c in interfaces.create], ['eth2'])
|
|
78
|
+
self.assertEqual(len(interfaces.update), 1)
|
|
79
|
+
update = interfaces.update[0]
|
|
80
|
+
self.assertEqual(update.name, 'eth0')
|
|
81
|
+
self.assertEqual(set(update.changes), {'type', 'poe_mode'})
|
|
82
|
+
self.assertEqual(update.changes['type'].current, '1000BASE-T (1GE)')
|
|
83
|
+
self.assertEqual(update.changes['type'].desired, 'SFP+ (10GE)')
|
|
84
|
+
self.assertEqual(update.changes['poe_mode'].current, '—')
|
|
85
|
+
self.assertEqual(update.changes['poe_mode'].desired, 'PSE')
|
|
86
|
+
# eth1 exists only on the device: ignored
|
|
87
|
+
self.assertNotIn('eth1', [c.name for c in interfaces.update])
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
[s.selection_key for s in interfaces.property_summaries],
|
|
90
|
+
['interface.type', 'interface.poe_mode'],
|
|
91
|
+
)
|
|
92
|
+
self.assertEqual(plan.create_count, 1)
|
|
93
|
+
|
|
94
|
+
def test_description_is_compared(self):
|
|
95
|
+
# NetBox does not copy the description to a new device
|
|
96
|
+
device_type = self.create_device_type('Type 1')
|
|
97
|
+
InterfaceTemplate.objects.create(
|
|
98
|
+
device_type=device_type,
|
|
99
|
+
name='eth0',
|
|
100
|
+
type='1000base-t',
|
|
101
|
+
description='Uplink',
|
|
102
|
+
)
|
|
103
|
+
device = self.create_device(device_type)
|
|
104
|
+
|
|
105
|
+
interfaces = kind_diff(build_plan(device), 'interface')
|
|
106
|
+
|
|
107
|
+
self.assertEqual(interfaces.update[0].changes['description'].desired, 'Uplink')
|
|
108
|
+
|
|
109
|
+
def test_virtual_chassis_position_is_resolved(self):
|
|
110
|
+
device_type = self.create_device_type('Type 1')
|
|
111
|
+
InterfaceTemplate.objects.create(
|
|
112
|
+
device_type=device_type,
|
|
113
|
+
name='Gi{vc_position}/0/1',
|
|
114
|
+
label='Port {vc_position}/1',
|
|
115
|
+
type='1000base-t',
|
|
116
|
+
)
|
|
117
|
+
vc = VirtualChassis.objects.create(name='VC 1')
|
|
118
|
+
device = self.create_device(device_type, virtual_chassis=vc, vc_position=2)
|
|
119
|
+
self.assertTrue(device.interfaces.filter(name='Gi2/0/1').exists())
|
|
120
|
+
InterfaceTemplate.objects.create(
|
|
121
|
+
device_type=device_type, name='Gi{vc_position}/0/2', type='1000base-t'
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
plan = build_plan(device)
|
|
125
|
+
interfaces = kind_diff(plan, 'interface')
|
|
126
|
+
|
|
127
|
+
self.assertEqual([c.name for c in interfaces.create], ['Gi2/0/2'])
|
|
128
|
+
self.assertEqual(interfaces.update, [])
|
|
129
|
+
|
|
130
|
+
def test_module_components_are_ignored(self):
|
|
131
|
+
device_type = self.create_device_type('Type 1')
|
|
132
|
+
ModuleBayTemplate.objects.create(device_type=device_type, name='Bay 1')
|
|
133
|
+
module_type = ModuleType.objects.create(
|
|
134
|
+
manufacturer=self.manufacturer, model='Module 1'
|
|
135
|
+
)
|
|
136
|
+
InterfaceTemplate.objects.create(
|
|
137
|
+
module_type=module_type, name='mod0', type='1000base-t'
|
|
138
|
+
)
|
|
139
|
+
device = self.create_device(device_type)
|
|
140
|
+
Module.objects.create(
|
|
141
|
+
device=device,
|
|
142
|
+
module_bay=device.modulebays.get(name='Bay 1'),
|
|
143
|
+
module_type=module_type,
|
|
144
|
+
)
|
|
145
|
+
InterfaceTemplate.objects.create(
|
|
146
|
+
device_type=device_type,
|
|
147
|
+
name='mod0',
|
|
148
|
+
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
interfaces = kind_diff(build_plan(device), 'interface')
|
|
152
|
+
|
|
153
|
+
self.assertEqual(interfaces.create, [])
|
|
154
|
+
self.assertEqual(interfaces.update, [])
|
|
155
|
+
self.assertEqual([c.name for c in interfaces.conflicts], ['mod0'])
|
|
156
|
+
|
|
157
|
+
def test_relations_are_compared_by_name(self):
|
|
158
|
+
device_type = self.create_device_type('Type 1')
|
|
159
|
+
lag = InterfaceTemplate.objects.create(
|
|
160
|
+
device_type=device_type, name='lag0', type='lag'
|
|
161
|
+
)
|
|
162
|
+
eth0 = InterfaceTemplate.objects.create(
|
|
163
|
+
device_type=device_type, name='eth0', type='virtual'
|
|
164
|
+
)
|
|
165
|
+
pp1 = PowerPortTemplate.objects.create(device_type=device_type, name='PSU1')
|
|
166
|
+
PowerPortTemplate.objects.create(device_type=device_type, name='PSU2')
|
|
167
|
+
outlet = PowerOutletTemplate.objects.create(
|
|
168
|
+
device_type=device_type, name='Out1', power_port=pp1
|
|
169
|
+
)
|
|
170
|
+
device = self.create_device(device_type)
|
|
171
|
+
eth0.parent = lag
|
|
172
|
+
eth0.save()
|
|
173
|
+
outlet.power_port = device_type.powerporttemplates.get(name='PSU2')
|
|
174
|
+
outlet.save()
|
|
175
|
+
|
|
176
|
+
plan = build_plan(device)
|
|
177
|
+
interface = kind_diff(plan, 'interface').update[0]
|
|
178
|
+
outlet_diff = kind_diff(plan, 'poweroutlet').update[0]
|
|
179
|
+
|
|
180
|
+
self.assertEqual(interface.changes['parent'].current, '—')
|
|
181
|
+
self.assertEqual(interface.changes['parent'].desired, 'lag0')
|
|
182
|
+
self.assertEqual(outlet_diff.changes['power_port'].current, 'PSU1')
|
|
183
|
+
self.assertEqual(outlet_diff.changes['power_port'].desired, 'PSU2')
|
|
184
|
+
|
|
185
|
+
def test_port_mappings(self):
|
|
186
|
+
device_type = self.create_device_type('Type 1')
|
|
187
|
+
rear = RearPortTemplate.objects.create(
|
|
188
|
+
device_type=device_type,
|
|
189
|
+
name='Rear',
|
|
190
|
+
type=PortTypeChoices.TYPE_LC,
|
|
191
|
+
positions=2,
|
|
192
|
+
)
|
|
193
|
+
front_1 = FrontPortTemplate.objects.create(
|
|
194
|
+
device_type=device_type, name='Front1', type=PortTypeChoices.TYPE_LC
|
|
195
|
+
)
|
|
196
|
+
mapping = PortTemplateMapping.objects.create(
|
|
197
|
+
front_port=front_1,
|
|
198
|
+
rear_port=rear,
|
|
199
|
+
front_port_position=1,
|
|
200
|
+
rear_port_position=1,
|
|
201
|
+
)
|
|
202
|
+
device = self.create_device(device_type)
|
|
203
|
+
self.assertTrue(build_plan(device).is_empty)
|
|
204
|
+
mapping.rear_port_position = 2
|
|
205
|
+
mapping.save()
|
|
206
|
+
|
|
207
|
+
front = kind_diff(build_plan(device), 'frontport').update[0]
|
|
208
|
+
|
|
209
|
+
self.assertEqual(front.changes['mappings'].current, '1 → Rear:1')
|
|
210
|
+
self.assertEqual(front.changes['mappings'].desired, '1 → Rear:2')
|
|
211
|
+
|
|
212
|
+
def test_inventory_items_are_matched_by_path(self):
|
|
213
|
+
# NetBox 4.7.4 cannot create a device from nested inventory item templates
|
|
214
|
+
# (Device._instantiate_components() instantiates all templates before it
|
|
215
|
+
# saves any), so the device items are created by hand.
|
|
216
|
+
device_type = self.create_device_type('Type 1')
|
|
217
|
+
device = self.create_device(device_type)
|
|
218
|
+
item = InventoryItem.objects.create(device=device, name='Chassis')
|
|
219
|
+
InventoryItem.objects.create(
|
|
220
|
+
device=device, name='Fan', parent=item, part_id='FAN-1'
|
|
221
|
+
)
|
|
222
|
+
InventoryItem.objects.create(device=device, name='Fan', part_id='FAN-1')
|
|
223
|
+
chassis = InventoryItemTemplate.objects.create(
|
|
224
|
+
device_type=device_type, name='Chassis'
|
|
225
|
+
)
|
|
226
|
+
fan = InventoryItemTemplate.objects.create(
|
|
227
|
+
device_type=device_type, name='Fan', parent=chassis, part_id='FAN-1'
|
|
228
|
+
)
|
|
229
|
+
self.assertTrue(build_plan(device).is_empty)
|
|
230
|
+
fan.part_id = 'FAN-2'
|
|
231
|
+
fan.save()
|
|
232
|
+
InventoryItemTemplate.objects.create(
|
|
233
|
+
device_type=device_type, name='PSU', parent=chassis
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
items = kind_diff(build_plan(device), 'inventoryitem')
|
|
237
|
+
|
|
238
|
+
self.assertEqual(items.update[0].name, 'Chassis / Fan')
|
|
239
|
+
self.assertEqual(items.update[0].changes['part_id'].desired, 'FAN-2')
|
|
240
|
+
self.assertEqual([c.name for c in items.create], ['Chassis / PSU'])
|
|
241
|
+
|
|
242
|
+
def test_module_bay_types(self):
|
|
243
|
+
device_type = self.create_device_type('Type 1')
|
|
244
|
+
bay = ModuleBayTemplate.objects.create(device_type=device_type, name='Bay 1')
|
|
245
|
+
device = self.create_device(device_type)
|
|
246
|
+
bay.module_bay_types.add(ModuleBayType.objects.create(name='SFP', slug='sfp'))
|
|
247
|
+
|
|
248
|
+
bays = kind_diff(build_plan(device), 'modulebay')
|
|
249
|
+
|
|
250
|
+
self.assertEqual(bays.update[0].changes['module_bay_types'].current, '—')
|
|
251
|
+
self.assertEqual(bays.update[0].changes['module_bay_types'].desired, 'SFP')
|
|
252
|
+
|
|
253
|
+
def test_values_with_units(self):
|
|
254
|
+
device_type = self.create_device_type('Type 1')
|
|
255
|
+
intake = CoolingIntakeTemplate.objects.create(
|
|
256
|
+
device_type=device_type, name='In1'
|
|
257
|
+
)
|
|
258
|
+
device = self.create_device(device_type)
|
|
259
|
+
intake.diameter = 25
|
|
260
|
+
intake.diameter_unit = DiameterUnitChoices.UNIT_MILLIMETER
|
|
261
|
+
intake.save()
|
|
262
|
+
|
|
263
|
+
change = (
|
|
264
|
+
kind_diff(build_plan(device), 'coolingintake').update[0].changes['diameter']
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
self.assertEqual(change.current, '—')
|
|
268
|
+
self.assertEqual(change.desired, '25.00 Millimeters')
|
|
269
|
+
|
|
270
|
+
def test_power_port_values(self):
|
|
271
|
+
device_type = self.create_device_type('Type 1')
|
|
272
|
+
PowerPortTemplate.objects.create(
|
|
273
|
+
device_type=device_type, name='PSU1', type=PowerPortTypeChoices.TYPE_IEC_C14
|
|
274
|
+
)
|
|
275
|
+
device = self.create_device(device_type)
|
|
276
|
+
device_type.powerporttemplates.update(maximum_draw=350)
|
|
277
|
+
|
|
278
|
+
changes = kind_diff(build_plan(device), 'powerport').update[0].changes
|
|
279
|
+
|
|
280
|
+
self.assertEqual(set(changes), {'maximum_draw'})
|
|
281
|
+
self.assertEqual(changes['maximum_draw'].desired, '350')
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class DeviceFieldDiffTestCase(SyncTestMixin, TestCase):
|
|
285
|
+
def test_device_fields(self):
|
|
286
|
+
platform = Platform.objects.create(name='Platform 1', slug='platform-1')
|
|
287
|
+
type_1 = self.create_device_type('Type 1')
|
|
288
|
+
type_2 = self.create_device_type(
|
|
289
|
+
'Type 2',
|
|
290
|
+
default_platform=platform,
|
|
291
|
+
airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR,
|
|
292
|
+
)
|
|
293
|
+
device = self.change_device_type(self.create_device(type_1), type_2)
|
|
294
|
+
|
|
295
|
+
plan = build_plan(device)
|
|
296
|
+
|
|
297
|
+
self.assertEqual(plan.selection_keys, ['device.platform', 'device.airflow'])
|
|
298
|
+
platform_diff, airflow_diff = plan.device_fields
|
|
299
|
+
self.assertEqual(platform_diff.current, '—')
|
|
300
|
+
self.assertEqual(platform_diff.desired, 'Platform 1')
|
|
301
|
+
self.assertEqual(airflow_diff.desired, 'Front to rear')
|
|
302
|
+
|
|
303
|
+
def test_empty_device_type_values_are_not_offered(self):
|
|
304
|
+
platform = Platform.objects.create(name='Platform 1', slug='platform-1')
|
|
305
|
+
type_1 = self.create_device_type(
|
|
306
|
+
'Type 1',
|
|
307
|
+
default_platform=platform,
|
|
308
|
+
airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR,
|
|
309
|
+
)
|
|
310
|
+
type_2 = self.create_device_type('Type 2')
|
|
311
|
+
device = self.change_device_type(self.create_device(type_1), type_2)
|
|
312
|
+
|
|
313
|
+
self.assertEqual(build_plan(device).device_fields, [])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.test import SimpleTestCase
|
|
3
|
+
|
|
4
|
+
from netbox_device_type_sync import __version__
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VersionTestCase(SimpleTestCase):
|
|
8
|
+
def test_version(self):
|
|
9
|
+
assert __version__ == '1.0.0'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PluginLoadTestCase(SimpleTestCase):
|
|
13
|
+
def test_plugin_is_installed(self):
|
|
14
|
+
config = apps.get_app_config('netbox_device_type_sync')
|
|
15
|
+
self.assertEqual(config.version, __version__)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests of the Sync button on the Device page. The button relies on NetBox
|
|
3
|
+
internals (see CLAUDE.md); these tests must pass for every supported NetBox
|
|
4
|
+
version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from django.test import SimpleTestCase
|
|
11
|
+
from django.urls import reverse
|
|
12
|
+
|
|
13
|
+
from core.models import ObjectType
|
|
14
|
+
from dcim.models import Device
|
|
15
|
+
from dcim.ui.panels import DeviceDeviceTypePanel
|
|
16
|
+
from dcim.views import DeviceView
|
|
17
|
+
from netbox.ui import layout
|
|
18
|
+
from users.models import ObjectPermission
|
|
19
|
+
from utilities.testing import TestCase
|
|
20
|
+
|
|
21
|
+
from netbox_device_type_sync.tests.utils import SyncTestMixin
|
|
22
|
+
from netbox_device_type_sync.ui import SyncDeviceAction, add_sync_button
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def device_type_panels():
|
|
26
|
+
return [
|
|
27
|
+
panel
|
|
28
|
+
for row in DeviceView.layout
|
|
29
|
+
for column in row
|
|
30
|
+
for panel in column
|
|
31
|
+
if isinstance(panel, DeviceDeviceTypePanel)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AddSyncButtonTestCase(SimpleTestCase):
|
|
36
|
+
def test_button_is_added_on_startup(self):
|
|
37
|
+
panels = device_type_panels()
|
|
38
|
+
|
|
39
|
+
self.assertEqual(len(panels), 1)
|
|
40
|
+
actions = [a for a in panels[0].actions if isinstance(a, SyncDeviceAction)]
|
|
41
|
+
self.assertEqual(len(actions), 1)
|
|
42
|
+
|
|
43
|
+
def test_adding_twice_adds_one_button(self):
|
|
44
|
+
self.assertTrue(add_sync_button())
|
|
45
|
+
|
|
46
|
+
actions = device_type_panels()[0].actions
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
len([a for a in actions if isinstance(a, SyncDeviceAction)]), 1
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def test_missing_panel_logs_a_warning(self):
|
|
52
|
+
other_layout = layout.SimpleLayout(left_panels=[], right_panels=[])
|
|
53
|
+
with mock.patch.object(DeviceView, 'layout', other_layout):
|
|
54
|
+
with self.assertLogs(
|
|
55
|
+
'netbox.plugins.netbox_device_type_sync', 'WARNING'
|
|
56
|
+
) as logs:
|
|
57
|
+
self.assertFalse(add_sync_button())
|
|
58
|
+
|
|
59
|
+
self.assertIn('Sync button was not added', logs.output[0])
|
|
60
|
+
|
|
61
|
+
def test_changed_netbox_internals_log_a_warning(self):
|
|
62
|
+
with mock.patch('dcim.views.DeviceView.layout', None):
|
|
63
|
+
with self.assertLogs('netbox.plugins.netbox_device_type_sync', 'WARNING'):
|
|
64
|
+
self.assertFalse(add_sync_button())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SyncButtonViewTestCase(SyncTestMixin, TestCase):
|
|
68
|
+
def setUp(self):
|
|
69
|
+
super().setUp()
|
|
70
|
+
self.device = self.create_device(self.create_device_type('Type 1'))
|
|
71
|
+
self.sync_url = reverse(
|
|
72
|
+
'plugins:netbox_device_type_sync:device_sync', kwargs={'pk': self.device.pk}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def device_type_card(self):
|
|
76
|
+
response = self.client.get(self.device.get_absolute_url())
|
|
77
|
+
self.assertEqual(response.status_code, 200)
|
|
78
|
+
match = re.search(
|
|
79
|
+
r'<!-- begin DeviceDeviceTypePanel -->(.*?)<!-- end DeviceDeviceTypePanel -->',
|
|
80
|
+
response.content.decode(),
|
|
81
|
+
re.DOTALL,
|
|
82
|
+
)
|
|
83
|
+
self.assertIsNotNone(
|
|
84
|
+
match, 'The Device Type card is missing on the Device page'
|
|
85
|
+
)
|
|
86
|
+
return match.group(1)
|
|
87
|
+
|
|
88
|
+
def test_button_in_device_type_card(self):
|
|
89
|
+
self.add_permissions('dcim.view_device', 'dcim.change_device')
|
|
90
|
+
|
|
91
|
+
card = self.device_type_card()
|
|
92
|
+
|
|
93
|
+
self.assertIn(f'href="{self.sync_url}"', card)
|
|
94
|
+
self.assertIn('mdi-sync', card)
|
|
95
|
+
|
|
96
|
+
def test_button_hidden_without_change_permission(self):
|
|
97
|
+
self.add_permissions('dcim.view_device')
|
|
98
|
+
|
|
99
|
+
self.assertNotIn(self.sync_url, self.device_type_card())
|
|
100
|
+
|
|
101
|
+
def test_button_hidden_by_object_constraints(self):
|
|
102
|
+
self.add_permissions('dcim.view_device')
|
|
103
|
+
permission = ObjectPermission.objects.create(
|
|
104
|
+
name='Change other devices',
|
|
105
|
+
actions=['change'],
|
|
106
|
+
constraints={'name': 'Other'},
|
|
107
|
+
)
|
|
108
|
+
permission.users.add(self.user)
|
|
109
|
+
permission.object_types.add(ObjectType.objects.get_for_model(Device))
|
|
110
|
+
|
|
111
|
+
self.assertNotIn(self.sync_url, self.device_type_card())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from django.urls import reverse
|
|
4
|
+
|
|
5
|
+
from core.choices import ObjectChangeActionChoices
|
|
6
|
+
from core.models import ObjectChange
|
|
7
|
+
from dcim.choices import InterfaceTypeChoices
|
|
8
|
+
from dcim.models import Cable, InterfaceTemplate
|
|
9
|
+
from utilities.testing import TestCase
|
|
10
|
+
|
|
11
|
+
from netbox_device_type_sync.tests.utils import SyncTestMixin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeviceSyncViewTestCase(SyncTestMixin, TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
super().setUp()
|
|
17
|
+
self.type_1 = self.create_device_type('Type 1')
|
|
18
|
+
InterfaceTemplate.objects.create(
|
|
19
|
+
device_type=self.type_1, name='eth0', type='1000base-t'
|
|
20
|
+
)
|
|
21
|
+
self.type_2 = self.create_device_type('Type 2')
|
|
22
|
+
InterfaceTemplate.objects.create(
|
|
23
|
+
device_type=self.type_2,
|
|
24
|
+
name='eth0',
|
|
25
|
+
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
26
|
+
)
|
|
27
|
+
InterfaceTemplate.objects.create(
|
|
28
|
+
device_type=self.type_2, name='eth1', type='1000base-t'
|
|
29
|
+
)
|
|
30
|
+
self.device = self.change_device_type(
|
|
31
|
+
self.create_device(self.type_1), self.type_2
|
|
32
|
+
)
|
|
33
|
+
self.url = reverse(
|
|
34
|
+
'plugins:netbox_device_type_sync:device_sync', kwargs={'pk': self.device.pk}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def test_get_without_permission(self):
|
|
38
|
+
self.add_permissions('dcim.view_device')
|
|
39
|
+
|
|
40
|
+
response = self.client.get(self.url)
|
|
41
|
+
|
|
42
|
+
self.assertEqual(response.status_code, 403)
|
|
43
|
+
|
|
44
|
+
def test_get(self):
|
|
45
|
+
self.add_permissions('dcim.change_device')
|
|
46
|
+
|
|
47
|
+
response = self.client.get(self.url)
|
|
48
|
+
|
|
49
|
+
self.assertEqual(response.status_code, 200)
|
|
50
|
+
content = response.content.decode()
|
|
51
|
+
self.assertIn('name="interface__type"', content)
|
|
52
|
+
# All checkboxes are unchecked
|
|
53
|
+
self.assertIsNone(re.search(r'<input type="checkbox"[^>]*\schecked', content))
|
|
54
|
+
self.assertIn('SFP+ (10GE)', content)
|
|
55
|
+
self.assertIn('eth1', content)
|
|
56
|
+
self.assertIn('1 interface will be created', content)
|
|
57
|
+
|
|
58
|
+
def test_get_in_sync(self):
|
|
59
|
+
self.add_permissions('dcim.change_device')
|
|
60
|
+
device = self.create_device(self.type_2, name='Device 2')
|
|
61
|
+
url = reverse(
|
|
62
|
+
'plugins:netbox_device_type_sync:device_sync', kwargs={'pk': device.pk}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
response = self.client.get(url)
|
|
66
|
+
|
|
67
|
+
self.assertEqual(response.status_code, 200)
|
|
68
|
+
self.assertIn(
|
|
69
|
+
'The device is in sync with its device type', response.content.decode()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def test_post(self):
|
|
73
|
+
self.add_permissions(
|
|
74
|
+
'dcim.change_device', 'dcim.add_interface', 'dcim.change_interface'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
response = self.client.post(self.url, {'interface__type': 'on'})
|
|
78
|
+
|
|
79
|
+
self.assertRedirects(
|
|
80
|
+
response, self.device.get_absolute_url(), fetch_redirect_response=False
|
|
81
|
+
)
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
self.device.interfaces.get(name='eth0').type,
|
|
84
|
+
InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
85
|
+
)
|
|
86
|
+
self.assertTrue(self.device.interfaces.filter(name='eth1').exists())
|
|
87
|
+
changes = ObjectChange.objects.filter(message='Synced with device type Type 2')
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
sorted(changes.values_list('action', flat=True)),
|
|
90
|
+
[
|
|
91
|
+
ObjectChangeActionChoices.ACTION_CREATE,
|
|
92
|
+
ObjectChangeActionChoices.ACTION_UPDATE,
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def test_post_unchecked_keeps_values(self):
|
|
97
|
+
self.add_permissions(
|
|
98
|
+
'dcim.change_device', 'dcim.add_interface', 'dcim.change_interface'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.client.post(self.url, {})
|
|
102
|
+
|
|
103
|
+
self.assertEqual(self.device.interfaces.get(name='eth0').type, '1000base-t')
|
|
104
|
+
self.assertTrue(self.device.interfaces.filter(name='eth1').exists())
|
|
105
|
+
|
|
106
|
+
def test_post_with_errors(self):
|
|
107
|
+
self.add_permissions(
|
|
108
|
+
'dcim.change_device', 'dcim.add_interface', 'dcim.change_interface'
|
|
109
|
+
)
|
|
110
|
+
peer = self.create_device(self.type_1, name='Peer')
|
|
111
|
+
Cable(
|
|
112
|
+
a_terminations=[self.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
|
+
|
|
117
|
+
response = self.client.post(self.url, {'interface__type': 'on'})
|
|
118
|
+
|
|
119
|
+
self.assertEqual(response.status_code, 200)
|
|
120
|
+
content = response.content.decode()
|
|
121
|
+
self.assertIn('Nothing was changed', content)
|
|
122
|
+
self.assertIn('Interface eth0', content)
|
|
123
|
+
self.assertFalse(self.device.interfaces.filter(name='eth1').exists())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SyncTestMixin:
|
|
5
|
+
"""
|
|
6
|
+
Objects shared by the tests: a site, a manufacturer and a device role.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def setUpTestData(cls):
|
|
11
|
+
cls.site = Site.objects.create(name='Site 1', slug='site-1')
|
|
12
|
+
cls.manufacturer = Manufacturer.objects.create(
|
|
13
|
+
name='Manufacturer 1', slug='manufacturer-1'
|
|
14
|
+
)
|
|
15
|
+
cls.role = DeviceRole.objects.create(name='Role 1', slug='role-1')
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def create_device_type(cls, model, **kwargs):
|
|
19
|
+
return DeviceType.objects.create(
|
|
20
|
+
manufacturer=cls.manufacturer,
|
|
21
|
+
model=model,
|
|
22
|
+
slug=model.lower().replace(' ', '-'),
|
|
23
|
+
**kwargs,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create_device(cls, device_type, name='Device 1', **kwargs):
|
|
28
|
+
return Device.objects.create(
|
|
29
|
+
device_type=device_type,
|
|
30
|
+
role=cls.role,
|
|
31
|
+
site=cls.site,
|
|
32
|
+
name=name,
|
|
33
|
+
**kwargs,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def change_device_type(device, device_type):
|
|
38
|
+
device.device_type = device_type
|
|
39
|
+
device.full_clean()
|
|
40
|
+
device.save()
|
|
41
|
+
return Device.objects.get(pk=device.pk)
|