nautobot 2.4.21__py3-none-any.whl → 2.4.22__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.
- nautobot/apps/choices.py +4 -0
- nautobot/apps/utils.py +8 -0
- nautobot/circuits/views.py +6 -2
- nautobot/core/cli/migrate_deprecated_templates.py +28 -9
- nautobot/core/filters.py +4 -0
- nautobot/core/forms/__init__.py +2 -0
- nautobot/core/forms/widgets.py +21 -2
- nautobot/core/settings.py +6 -0
- nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
- nautobot/core/templatetags/helpers.py +9 -7
- nautobot/core/tests/nautobot_config.py +3 -0
- nautobot/core/tests/test_templatetags_helpers.py +6 -0
- nautobot/core/tests/test_ui.py +49 -1
- nautobot/core/tests/test_utils.py +41 -1
- nautobot/core/ui/object_detail.py +7 -2
- nautobot/core/urls.py +7 -8
- nautobot/core/utils/filtering.py +11 -1
- nautobot/core/utils/lookup.py +46 -0
- nautobot/core/views/mixins.py +21 -16
- nautobot/dcim/api/serializers.py +3 -0
- nautobot/dcim/choices.py +49 -0
- nautobot/dcim/constants.py +7 -0
- nautobot/dcim/filters/__init__.py +7 -0
- nautobot/dcim/forms.py +89 -3
- nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
- nautobot/dcim/models/device_component_templates.py +33 -1
- nautobot/dcim/models/device_components.py +21 -0
- nautobot/dcim/tables/devices.py +14 -0
- nautobot/dcim/tables/devicetypes.py +8 -1
- nautobot/dcim/templates/dcim/interface.html +8 -0
- nautobot/dcim/templates/dcim/interface_edit.html +2 -0
- nautobot/dcim/tests/test_api.py +186 -6
- nautobot/dcim/tests/test_filters.py +32 -0
- nautobot/dcim/tests/test_forms.py +110 -8
- nautobot/dcim/tests/test_graphql.py +44 -1
- nautobot/dcim/tests/test_models.py +265 -0
- nautobot/dcim/tests/test_tables.py +160 -0
- nautobot/dcim/tests/test_views.py +64 -1
- nautobot/dcim/views.py +86 -77
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/templates/extras/plugin_detail.html +2 -2
- nautobot/extras/urls.py +0 -14
- nautobot/extras/views.py +1 -1
- nautobot/ipam/ui.py +0 -17
- nautobot/ipam/views.py +2 -2
- nautobot/project-static/js/forms.js +92 -14
- nautobot/virtualization/tests/test_models.py +4 -2
- nautobot/virtualization/views.py +1 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/METADATA +4 -4
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/RECORD +54 -51
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/NOTICE +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/WHEEL +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/entry_points.txt +0 -0
nautobot/dcim/tables/devices.py
CHANGED
|
@@ -11,6 +11,7 @@ from nautobot.core.tables import (
|
|
|
11
11
|
TagColumn,
|
|
12
12
|
ToggleColumn,
|
|
13
13
|
)
|
|
14
|
+
from nautobot.core.templatetags.helpers import humanize_speed
|
|
14
15
|
from nautobot.dcim.models import (
|
|
15
16
|
ConsolePort,
|
|
16
17
|
ConsoleServerPort,
|
|
@@ -724,6 +725,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|
|
724
725
|
url_params={"interfaces": "pk"},
|
|
725
726
|
verbose_name="Virtual Device Contexts",
|
|
726
727
|
)
|
|
728
|
+
speed = tables.Column(verbose_name="Speed", accessor="speed", orderable=True)
|
|
729
|
+
duplex = tables.Column(verbose_name="Duplex", accessor="duplex", orderable=True)
|
|
727
730
|
|
|
728
731
|
class Meta(ModularDeviceComponentTable.Meta):
|
|
729
732
|
model = Interface
|
|
@@ -737,6 +740,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|
|
737
740
|
"label",
|
|
738
741
|
"enabled",
|
|
739
742
|
"type",
|
|
743
|
+
"speed",
|
|
744
|
+
"duplex",
|
|
740
745
|
"mgmt_only",
|
|
741
746
|
"mtu",
|
|
742
747
|
"vrf",
|
|
@@ -762,9 +767,13 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|
|
762
767
|
"label",
|
|
763
768
|
"enabled",
|
|
764
769
|
"type",
|
|
770
|
+
"speed",
|
|
765
771
|
"description",
|
|
766
772
|
)
|
|
767
773
|
|
|
774
|
+
def render_speed(self, record):
|
|
775
|
+
return humanize_speed(record.speed)
|
|
776
|
+
|
|
768
777
|
|
|
769
778
|
class DeviceModuleInterfaceTable(InterfaceTable):
|
|
770
779
|
name = tables.TemplateColumn(
|
|
@@ -790,6 +799,8 @@ class DeviceModuleInterfaceTable(InterfaceTable):
|
|
|
790
799
|
"module",
|
|
791
800
|
"enabled",
|
|
792
801
|
"type",
|
|
802
|
+
"speed",
|
|
803
|
+
"duplex",
|
|
793
804
|
"parent_interface",
|
|
794
805
|
"bridge",
|
|
795
806
|
"lag",
|
|
@@ -835,6 +846,9 @@ class DeviceModuleInterfaceTable(InterfaceTable):
|
|
|
835
846
|
"data-name": lambda record: record.name,
|
|
836
847
|
}
|
|
837
848
|
|
|
849
|
+
def render_speed(self, record):
|
|
850
|
+
return humanize_speed(record.speed)
|
|
851
|
+
|
|
838
852
|
|
|
839
853
|
class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
|
840
854
|
rear_port_position = tables.Column(verbose_name="Position")
|
|
@@ -8,6 +8,7 @@ from nautobot.core.tables import (
|
|
|
8
8
|
TagColumn,
|
|
9
9
|
ToggleColumn,
|
|
10
10
|
)
|
|
11
|
+
from nautobot.core.templatetags.helpers import humanize_speed
|
|
11
12
|
from nautobot.dcim.models import (
|
|
12
13
|
ConsolePortTemplate,
|
|
13
14
|
ConsoleServerPortTemplate,
|
|
@@ -270,6 +271,8 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
|
|
|
270
271
|
|
|
271
272
|
class InterfaceTemplateTable(ComponentTemplateTable):
|
|
272
273
|
mgmt_only = BooleanColumn(verbose_name="Management Only")
|
|
274
|
+
speed = tables.Column(verbose_name="Speed", accessor="speed", orderable=True)
|
|
275
|
+
duplex = tables.Column(verbose_name="Duplex", accessor="duplex", orderable=True)
|
|
273
276
|
actions = ButtonsColumn(
|
|
274
277
|
model=InterfaceTemplate,
|
|
275
278
|
buttons=("edit", "delete"),
|
|
@@ -278,9 +281,13 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
|
|
278
281
|
|
|
279
282
|
class Meta(BaseTable.Meta):
|
|
280
283
|
model = InterfaceTemplate
|
|
281
|
-
fields = ("pk", "name", "label", "mgmt_only", "type", "description", "actions")
|
|
284
|
+
fields = ("pk", "name", "label", "mgmt_only", "type", "speed", "duplex", "description", "actions")
|
|
285
|
+
default_columns = ("pk", "name", "label", "mgmt_only", "type", "speed", "description", "actions")
|
|
282
286
|
empty_text = "None"
|
|
283
287
|
|
|
288
|
+
def render_speed(self, record):
|
|
289
|
+
return humanize_speed(record.speed)
|
|
290
|
+
|
|
284
291
|
|
|
285
292
|
class FrontPortTemplateTable(ComponentTemplateTable):
|
|
286
293
|
rear_port_position = tables.Column(verbose_name="Position")
|
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
<td>Type</td>
|
|
45
45
|
<td>{{ object.get_type_display }}</td>
|
|
46
46
|
</tr>
|
|
47
|
+
<tr>
|
|
48
|
+
<td>Speed</td>
|
|
49
|
+
<td>{{ object.speed|humanize_speed|placeholder }}</td>
|
|
50
|
+
</tr>
|
|
51
|
+
<tr>
|
|
52
|
+
<td>Duplex</td>
|
|
53
|
+
<td>{{ object.get_duplex_display|placeholder }}</td>
|
|
54
|
+
</tr>
|
|
47
55
|
<tr>
|
|
48
56
|
<td>Enabled</td>
|
|
49
57
|
<td>{{ object.enabled | render_boolean }}</td>
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
{% render_field form.status %}
|
|
13
13
|
{% render_field form.role %}
|
|
14
14
|
{% render_field form.type %}
|
|
15
|
+
{% render_field form.speed %}
|
|
16
|
+
{% render_field form.duplex %}
|
|
15
17
|
{% render_field form.enabled %}
|
|
16
18
|
{% render_field form.parent_interface %}
|
|
17
19
|
{% render_field form.bridge %}
|
nautobot/dcim/tests/test_api.py
CHANGED
|
@@ -13,7 +13,9 @@ from nautobot.core.testing import APITestCase, APIViewTestCases
|
|
|
13
13
|
from nautobot.core.testing.utils import generate_random_device_asset_tag_of_specified_size, get_deletable_objects
|
|
14
14
|
from nautobot.dcim.choices import (
|
|
15
15
|
ConsolePortTypeChoices,
|
|
16
|
+
InterfaceDuplexChoices,
|
|
16
17
|
InterfaceModeChoices,
|
|
18
|
+
InterfaceSpeedChoices,
|
|
17
19
|
InterfaceTypeChoices,
|
|
18
20
|
PortTypeChoices,
|
|
19
21
|
PowerFeedBreakerPoleChoices,
|
|
@@ -1176,6 +1178,7 @@ class PowerOutletTemplateTest(Mixins.ModularDeviceComponentTemplateMixin, Mixins
|
|
|
1176
1178
|
|
|
1177
1179
|
class InterfaceTemplateTest(Mixins.ModularDeviceComponentTemplateMixin, Mixins.BasePortTemplateTestMixin):
|
|
1178
1180
|
model = InterfaceTemplate
|
|
1181
|
+
choices_fields = ["duplex", "type"]
|
|
1179
1182
|
modular_component_create_data = {"type": InterfaceTypeChoices.TYPE_1GE_FIXED}
|
|
1180
1183
|
|
|
1181
1184
|
@classmethod
|
|
@@ -1199,6 +1202,62 @@ class InterfaceTemplateTest(Mixins.ModularDeviceComponentTemplateMixin, Mixins.B
|
|
|
1199
1202
|
},
|
|
1200
1203
|
]
|
|
1201
1204
|
|
|
1205
|
+
def test_create_base_t_with_speed_and_duplex(self):
|
|
1206
|
+
self.add_permissions("dcim.add_interfacetemplate", "dcim.view_interfacetemplate", "dcim.view_devicetype")
|
|
1207
|
+
url = self._get_list_url()
|
|
1208
|
+
payload = {
|
|
1209
|
+
"device_type": self.device_type.pk,
|
|
1210
|
+
"name": "Eth1",
|
|
1211
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
1212
|
+
"mgmt_only": False,
|
|
1213
|
+
"speed": InterfaceSpeedChoices.SPEED_1G,
|
|
1214
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
1215
|
+
}
|
|
1216
|
+
response = self.client.post(url, data=payload, format="json", **self.header)
|
|
1217
|
+
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
1218
|
+
obj = InterfaceTemplate.objects.get(pk=response.data["id"]) # type: ignore[index]
|
|
1219
|
+
self.assertEqual(obj.speed, InterfaceSpeedChoices.SPEED_1G)
|
|
1220
|
+
self.assertEqual(obj.duplex, InterfaceDuplexChoices.DUPLEX_FULL)
|
|
1221
|
+
|
|
1222
|
+
def test_create_sfp_with_duplex_rejected(self):
|
|
1223
|
+
self.add_permissions("dcim.add_interfacetemplate", "dcim.view_interfacetemplate", "dcim.view_devicetype")
|
|
1224
|
+
url = self._get_list_url()
|
|
1225
|
+
payload = {
|
|
1226
|
+
"device_type": self.device_type.pk,
|
|
1227
|
+
"name": "SFP1",
|
|
1228
|
+
"type": InterfaceTypeChoices.TYPE_1GE_SFP,
|
|
1229
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
1230
|
+
}
|
|
1231
|
+
response = self.client.post(url, data=payload, format="json", **self.header)
|
|
1232
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
1233
|
+
self.assertIn("duplex", response.data)
|
|
1234
|
+
|
|
1235
|
+
def test_create_lag_with_speed_rejected(self):
|
|
1236
|
+
self.add_permissions("dcim.add_interfacetemplate", "dcim.view_interfacetemplate", "dcim.view_devicetype")
|
|
1237
|
+
url = self._get_list_url()
|
|
1238
|
+
payload = {
|
|
1239
|
+
"device_type": self.device_type.pk,
|
|
1240
|
+
"name": "Port-Channel1",
|
|
1241
|
+
"type": InterfaceTypeChoices.TYPE_LAG,
|
|
1242
|
+
"speed": InterfaceSpeedChoices.SPEED_1G,
|
|
1243
|
+
}
|
|
1244
|
+
response = self.client.post(url, data=payload, format="json", **self.header)
|
|
1245
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
1246
|
+
self.assertIn("speed", response.data)
|
|
1247
|
+
|
|
1248
|
+
def test_create_virtual_with_speed_rejected(self):
|
|
1249
|
+
self.add_permissions("dcim.add_interfacetemplate", "dcim.view_interfacetemplate", "dcim.view_devicetype")
|
|
1250
|
+
url = self._get_list_url()
|
|
1251
|
+
payload = {
|
|
1252
|
+
"device_type": self.device_type.pk,
|
|
1253
|
+
"name": "V0",
|
|
1254
|
+
"type": InterfaceTypeChoices.TYPE_VIRTUAL,
|
|
1255
|
+
"speed": InterfaceSpeedChoices.SPEED_1G,
|
|
1256
|
+
}
|
|
1257
|
+
response = self.client.post(url, data=payload, format="json", **self.header)
|
|
1258
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
1259
|
+
self.assertIn("speed", response.data)
|
|
1260
|
+
|
|
1202
1261
|
|
|
1203
1262
|
class FrontPortTemplateTest(Mixins.BasePortTemplateTestMixin):
|
|
1204
1263
|
model = FrontPortTemplate
|
|
@@ -2165,7 +2224,7 @@ class PowerOutletTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMix
|
|
|
2165
2224
|
class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin):
|
|
2166
2225
|
model = Interface
|
|
2167
2226
|
peer_termination_type = Interface
|
|
2168
|
-
choices_fields = ["mode", "type"]
|
|
2227
|
+
choices_fields = ["duplex", "mode", "type"]
|
|
2169
2228
|
|
|
2170
2229
|
@classmethod
|
|
2171
2230
|
def setUpTestData(cls):
|
|
@@ -2206,14 +2265,14 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
|
|
|
2206
2265
|
Interface.objects.create(
|
|
2207
2266
|
device=cls.devices[0],
|
|
2208
2267
|
name="Test Interface 1",
|
|
2209
|
-
type=
|
|
2268
|
+
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2210
2269
|
status=non_default_status,
|
|
2211
2270
|
role=intf_role,
|
|
2212
2271
|
),
|
|
2213
2272
|
Interface.objects.create(
|
|
2214
2273
|
device=cls.devices[0],
|
|
2215
2274
|
name="Test Interface 2",
|
|
2216
|
-
type=
|
|
2275
|
+
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2217
2276
|
status=non_default_status,
|
|
2218
2277
|
),
|
|
2219
2278
|
Interface.objects.create(
|
|
@@ -2264,7 +2323,7 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
|
|
|
2264
2323
|
{
|
|
2265
2324
|
"device": cls.devices[0].pk,
|
|
2266
2325
|
"name": "Test Interface 8",
|
|
2267
|
-
"type":
|
|
2326
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2268
2327
|
"status": interface_status.pk,
|
|
2269
2328
|
"role": intf_role.pk,
|
|
2270
2329
|
"mode": InterfaceModeChoices.MODE_TAGGED,
|
|
@@ -2275,7 +2334,7 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
|
|
|
2275
2334
|
{
|
|
2276
2335
|
"device": cls.devices[0].pk,
|
|
2277
2336
|
"name": "Test Interface 9",
|
|
2278
|
-
"type":
|
|
2337
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2279
2338
|
"status": interface_status.pk,
|
|
2280
2339
|
"role": intf_role.pk,
|
|
2281
2340
|
"mode": InterfaceModeChoices.MODE_TAGGED,
|
|
@@ -2287,13 +2346,35 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
|
|
|
2287
2346
|
{
|
|
2288
2347
|
"device": cls.devices[0].pk,
|
|
2289
2348
|
"name": "Test Interface 10",
|
|
2290
|
-
"type":
|
|
2349
|
+
"type": InterfaceTypeChoices.TYPE_VIRTUAL,
|
|
2291
2350
|
"status": interface_status.pk,
|
|
2292
2351
|
"mode": InterfaceModeChoices.MODE_TAGGED,
|
|
2293
2352
|
"parent_interface": cls.interfaces[1].pk,
|
|
2294
2353
|
"tagged_vlans": [cls.vlans[0].pk, cls.vlans[1].pk],
|
|
2295
2354
|
"untagged_vlan": cls.vlans[2].pk,
|
|
2296
2355
|
},
|
|
2356
|
+
{
|
|
2357
|
+
"device": cls.devices[0].pk,
|
|
2358
|
+
"name": "Test Interface 11",
|
|
2359
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2360
|
+
"status": interface_status.pk,
|
|
2361
|
+
"speed": InterfaceSpeedChoices.SPEED_1G,
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
"device": cls.devices[0].pk,
|
|
2365
|
+
"name": "Test Interface 12",
|
|
2366
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2367
|
+
"status": interface_status.pk,
|
|
2368
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
2369
|
+
},
|
|
2370
|
+
{
|
|
2371
|
+
"device": cls.devices[0].pk,
|
|
2372
|
+
"name": "Test Interface 13",
|
|
2373
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
2374
|
+
"status": interface_status.pk,
|
|
2375
|
+
"speed": InterfaceSpeedChoices.SPEED_1G,
|
|
2376
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
2377
|
+
},
|
|
2297
2378
|
]
|
|
2298
2379
|
|
|
2299
2380
|
cls.untagged_vlan_data = {
|
|
@@ -2502,6 +2583,105 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
|
|
|
2502
2583
|
response = self.client.patch(self._get_detail_url(interface), data=payload, format="json", **self.header)
|
|
2503
2584
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
2504
2585
|
|
|
2586
|
+
def test_speed_duplex_invalid_by_type(self):
|
|
2587
|
+
"""Test that API rejects speed/duplex for disallowed interface types."""
|
|
2588
|
+
self.add_permissions("dcim.add_interface", "dcim.view_interface", "dcim.view_device", "extras.view_status")
|
|
2589
|
+
|
|
2590
|
+
# LAG disallows speed/duplex
|
|
2591
|
+
for field, value in (("speed", InterfaceSpeedChoices.SPEED_1G), ("duplex", InterfaceDuplexChoices.DUPLEX_FULL)):
|
|
2592
|
+
with self.subTest(if_type=InterfaceTypeChoices.TYPE_LAG, field=field):
|
|
2593
|
+
payload = {
|
|
2594
|
+
"device": self.devices[0].pk,
|
|
2595
|
+
"name": f"if-lag-{field}",
|
|
2596
|
+
"type": InterfaceTypeChoices.TYPE_LAG,
|
|
2597
|
+
"status": Status.objects.get_for_model(Interface).first().pk,
|
|
2598
|
+
field: value,
|
|
2599
|
+
}
|
|
2600
|
+
response = self.client.post(self._get_list_url(), data=payload, format="json", **self.header)
|
|
2601
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
2602
|
+
self.assertIn(field, response.data)
|
|
2603
|
+
|
|
2604
|
+
# Virtual/wireless disallow speed/duplex
|
|
2605
|
+
for if_type in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_80211AC):
|
|
2606
|
+
for field, value in (
|
|
2607
|
+
("speed", InterfaceSpeedChoices.SPEED_1G),
|
|
2608
|
+
("duplex", InterfaceDuplexChoices.DUPLEX_FULL),
|
|
2609
|
+
):
|
|
2610
|
+
with self.subTest(if_type=if_type, field=field):
|
|
2611
|
+
payload = {
|
|
2612
|
+
"device": self.devices[0].pk,
|
|
2613
|
+
"name": f"if-{if_type}-{field}",
|
|
2614
|
+
"type": if_type,
|
|
2615
|
+
"status": Status.objects.get_for_model(Interface).first().pk,
|
|
2616
|
+
field: value,
|
|
2617
|
+
}
|
|
2618
|
+
response = self.client.post(self._get_list_url(), data=payload, format="json", **self.header)
|
|
2619
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
2620
|
+
self.assertIn(field, response.data)
|
|
2621
|
+
|
|
2622
|
+
# Optical disallows duplex
|
|
2623
|
+
with self.subTest(if_type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, field="duplex"):
|
|
2624
|
+
payload = {
|
|
2625
|
+
"device": self.devices[0].pk,
|
|
2626
|
+
"name": "if-opt-duplex",
|
|
2627
|
+
"type": InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
2628
|
+
"status": Status.objects.get_for_model(Interface).first().pk,
|
|
2629
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
2630
|
+
}
|
|
2631
|
+
response = self.client.post(self._get_list_url(), data=payload, format="json", **self.header)
|
|
2632
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
2633
|
+
self.assertIn("duplex", response.data)
|
|
2634
|
+
|
|
2635
|
+
def test_update_type_to_optical_fails_when_duplex_set(self):
|
|
2636
|
+
"""Test that changing a copper interface with duplex set to an optical type fails."""
|
|
2637
|
+
self.add_permissions("dcim.change_interface")
|
|
2638
|
+
interface = self.interfaces[0] # 1000base-t
|
|
2639
|
+
|
|
2640
|
+
# Ensure duplex is set on copper via API
|
|
2641
|
+
response = self.client.patch(
|
|
2642
|
+
self._get_detail_url(interface),
|
|
2643
|
+
data={"duplex": InterfaceDuplexChoices.DUPLEX_FULL},
|
|
2644
|
+
format="json",
|
|
2645
|
+
**self.header,
|
|
2646
|
+
)
|
|
2647
|
+
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
2648
|
+
self.assertEqual(response.data["duplex"]["value"], InterfaceDuplexChoices.DUPLEX_FULL)
|
|
2649
|
+
|
|
2650
|
+
# Attempt to change type to optical while duplex remains set
|
|
2651
|
+
response = self.client.patch(
|
|
2652
|
+
self._get_detail_url(interface),
|
|
2653
|
+
data={"type": InterfaceTypeChoices.TYPE_10GE_SFP_PLUS},
|
|
2654
|
+
format="json",
|
|
2655
|
+
**self.header,
|
|
2656
|
+
)
|
|
2657
|
+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
|
2658
|
+
self.assertIn("duplex", response.data)
|
|
2659
|
+
|
|
2660
|
+
def test_update_type_to_optical_succeeds_when_unsetting_duplex(self):
|
|
2661
|
+
"""Test that changing type with duplex set to optical while unsetting duplex in the same request succeeds."""
|
|
2662
|
+
self.add_permissions("dcim.change_interface")
|
|
2663
|
+
interface = self.interfaces[1] # 1000base-t
|
|
2664
|
+
|
|
2665
|
+
# Ensure duplex is set on copper first
|
|
2666
|
+
response = self.client.patch(
|
|
2667
|
+
self._get_detail_url(interface),
|
|
2668
|
+
data={"duplex": InterfaceDuplexChoices.DUPLEX_FULL},
|
|
2669
|
+
format="json",
|
|
2670
|
+
**self.header,
|
|
2671
|
+
)
|
|
2672
|
+
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
2673
|
+
self.assertEqual(response.data["duplex"]["value"], InterfaceDuplexChoices.DUPLEX_FULL)
|
|
2674
|
+
|
|
2675
|
+
# Change to optical and unset duplex in same call
|
|
2676
|
+
response = self.client.patch(
|
|
2677
|
+
self._get_detail_url(interface),
|
|
2678
|
+
data={"type": InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, "duplex": ""},
|
|
2679
|
+
format="json",
|
|
2680
|
+
**self.header,
|
|
2681
|
+
)
|
|
2682
|
+
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
2683
|
+
self.assertIsNone(response.data["duplex"])
|
|
2684
|
+
|
|
2505
2685
|
|
|
2506
2686
|
class FrontPortTest(Mixins.BasePortTestMixin):
|
|
2507
2687
|
model = FrontPort
|
|
@@ -11,7 +11,9 @@ from nautobot.dcim.choices import (
|
|
|
11
11
|
CableLengthUnitChoices,
|
|
12
12
|
CableTypeChoices,
|
|
13
13
|
DeviceFaceChoices,
|
|
14
|
+
InterfaceDuplexChoices,
|
|
14
15
|
InterfaceModeChoices,
|
|
16
|
+
InterfaceSpeedChoices,
|
|
15
17
|
InterfaceTypeChoices,
|
|
16
18
|
PortTypeChoices,
|
|
17
19
|
PowerFeedBreakerPoleChoices,
|
|
@@ -2262,6 +2264,8 @@ class InterfaceTestCase(PathEndpointModelTestMixin, ModularDeviceComponentTestMi
|
|
|
2262
2264
|
("name",),
|
|
2263
2265
|
("parent_interface", "parent_interface__id"),
|
|
2264
2266
|
("parent_interface", "parent_interface__name"),
|
|
2267
|
+
("speed",),
|
|
2268
|
+
("duplex",),
|
|
2265
2269
|
("role", "role__id"),
|
|
2266
2270
|
("role", "role__name"),
|
|
2267
2271
|
("status", "status__id"),
|
|
@@ -2341,6 +2345,8 @@ class InterfaceTestCase(PathEndpointModelTestMixin, ModularDeviceComponentTestMi
|
|
|
2341
2345
|
mtu=100,
|
|
2342
2346
|
status=interface_statuses[0],
|
|
2343
2347
|
untagged_vlan=vlans[0],
|
|
2348
|
+
speed=InterfaceSpeedChoices.SPEED_1G,
|
|
2349
|
+
duplex=InterfaceDuplexChoices.DUPLEX_FULL,
|
|
2344
2350
|
)
|
|
2345
2351
|
|
|
2346
2352
|
Interface.objects.filter(pk=cabled_interfaces[1].pk).update(
|
|
@@ -2350,6 +2356,8 @@ class InterfaceTestCase(PathEndpointModelTestMixin, ModularDeviceComponentTestMi
|
|
|
2350
2356
|
mtu=200,
|
|
2351
2357
|
status=interface_statuses[3],
|
|
2352
2358
|
untagged_vlan=vlans[1],
|
|
2359
|
+
speed=InterfaceSpeedChoices.SPEED_10G,
|
|
2360
|
+
duplex=InterfaceDuplexChoices.DUPLEX_HALF,
|
|
2353
2361
|
)
|
|
2354
2362
|
|
|
2355
2363
|
Interface.objects.filter(pk=cabled_interfaces[2].pk).update(
|
|
@@ -2363,6 +2371,16 @@ class InterfaceTestCase(PathEndpointModelTestMixin, ModularDeviceComponentTestMi
|
|
|
2363
2371
|
for interface in cabled_interfaces:
|
|
2364
2372
|
interface.refresh_from_db()
|
|
2365
2373
|
|
|
2374
|
+
# Additional optical interface for speed filtering (no duplex)
|
|
2375
|
+
Interface.objects.create(
|
|
2376
|
+
device=devices[2],
|
|
2377
|
+
name="Filter Optical IF",
|
|
2378
|
+
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
|
|
2379
|
+
status=interface_statuses[0],
|
|
2380
|
+
speed=InterfaceSpeedChoices.SPEED_10G,
|
|
2381
|
+
duplex="",
|
|
2382
|
+
)
|
|
2383
|
+
|
|
2366
2384
|
cable_statuses = Status.objects.get_for_model(Cable)
|
|
2367
2385
|
connected_status = cable_statuses.get(name="Connected")
|
|
2368
2386
|
|
|
@@ -2558,6 +2576,20 @@ class InterfaceTestCase(PathEndpointModelTestMixin, ModularDeviceComponentTestMi
|
|
|
2558
2576
|
params = {"mode": [InterfaceModeChoices.MODE_ACCESS]}
|
|
2559
2577
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
|
2560
2578
|
|
|
2579
|
+
def test_speed_multi(self):
|
|
2580
|
+
params = {"speed": [InterfaceSpeedChoices.SPEED_1G, InterfaceSpeedChoices.SPEED_10G]}
|
|
2581
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
2582
|
+
self.filterset(params, self.queryset).qs,
|
|
2583
|
+
self.queryset.filter(speed__in=params["speed"]),
|
|
2584
|
+
)
|
|
2585
|
+
|
|
2586
|
+
def test_speed_and_duplex(self):
|
|
2587
|
+
params = {"speed": [InterfaceSpeedChoices.SPEED_10G], "duplex": [InterfaceDuplexChoices.DUPLEX_HALF]}
|
|
2588
|
+
self.assertQuerysetEqualAndNotEmpty(
|
|
2589
|
+
self.filterset(params, self.queryset).qs,
|
|
2590
|
+
self.queryset.filter(speed__in=params["speed"], duplex__in=params["duplex"]),
|
|
2591
|
+
)
|
|
2592
|
+
|
|
2561
2593
|
def test_device_with_common_vc(self):
|
|
2562
2594
|
"""Assert only interfaces belonging to devices with common VC are returned"""
|
|
2563
2595
|
device_type = DeviceType.objects.first()
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
from constance.test import override_config
|
|
1
2
|
from django.test import TestCase
|
|
2
3
|
|
|
3
4
|
from nautobot.core.testing.forms import FormTestCases
|
|
4
5
|
from nautobot.core.testing.mixins import NautobotTestCaseMixin
|
|
5
|
-
from nautobot.dcim.choices import
|
|
6
|
+
from nautobot.dcim.choices import (
|
|
7
|
+
DeviceFaceChoices,
|
|
8
|
+
InterfaceDuplexChoices,
|
|
9
|
+
InterfaceModeChoices,
|
|
10
|
+
InterfaceSpeedChoices,
|
|
11
|
+
InterfaceTypeChoices,
|
|
12
|
+
RackWidthChoices,
|
|
13
|
+
)
|
|
14
|
+
from nautobot.dcim.constants import RACK_U_HEIGHT_DEFAULT
|
|
6
15
|
from nautobot.dcim.forms import (
|
|
7
16
|
DeviceFilterForm,
|
|
8
17
|
DeviceForm,
|
|
@@ -327,24 +336,56 @@ class RackTestCase(TestCase):
|
|
|
327
336
|
form = RackForm(data=data, instance=racks[0])
|
|
328
337
|
self.assertTrue(form.is_valid())
|
|
329
338
|
|
|
339
|
+
def test_rack_form_initial_u_height_default(self):
|
|
340
|
+
"""Test that RackForm sets initial u_height from default Constance config (42)."""
|
|
341
|
+
# Create a new form (not bound to an instance)
|
|
342
|
+
form = RackForm()
|
|
343
|
+
|
|
344
|
+
# The initial value should be 42 (default Constance config)
|
|
345
|
+
self.assertEqual(form.fields["u_height"].initial, RACK_U_HEIGHT_DEFAULT)
|
|
346
|
+
|
|
347
|
+
@override_config(RACK_DEFAULT_U_HEIGHT=48)
|
|
348
|
+
def test_rack_form_initial_u_height_custom(self):
|
|
349
|
+
"""Test that RackForm sets initial u_height from custom Constance config."""
|
|
350
|
+
# Create a new form (not bound to an instance)
|
|
351
|
+
form = RackForm()
|
|
352
|
+
|
|
353
|
+
# The initial value should be 48 (from Constance config)
|
|
354
|
+
self.assertEqual(form.fields["u_height"].initial, 48)
|
|
355
|
+
|
|
356
|
+
def test_rack_form_initial_u_height_not_set_on_edit(self):
|
|
357
|
+
"""Test that RackForm does NOT override u_height when editing an existing rack."""
|
|
358
|
+
location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
|
|
359
|
+
status = Status.objects.get(name="Active")
|
|
360
|
+
|
|
361
|
+
# Create a rack with u_height of 24
|
|
362
|
+
rack = Rack.objects.create(name="Test Rack", location=location, status=status, u_height=24)
|
|
363
|
+
|
|
364
|
+
# Create a form bound to the existing rack
|
|
365
|
+
form = RackForm(instance=rack)
|
|
366
|
+
|
|
367
|
+
# The initial value should NOT be overridden - it should use the rack's actual value
|
|
368
|
+
# (The form will show the model instance's value, not the Constance config)
|
|
369
|
+
self.assertEqual(form.initial["u_height"], 24)
|
|
370
|
+
|
|
330
371
|
|
|
331
372
|
class InterfaceTestCase(NautobotTestCaseMixin, TestCase):
|
|
332
373
|
@classmethod
|
|
333
374
|
def setUpTestData(cls):
|
|
334
375
|
cls.device = Device.objects.first()
|
|
335
|
-
status = Status.objects.get_for_model(Interface).first()
|
|
376
|
+
cls.status = Status.objects.get_for_model(Interface).first()
|
|
336
377
|
cls.interface = Interface.objects.create(
|
|
337
378
|
device=cls.device,
|
|
338
379
|
name="test interface form 0.0",
|
|
339
380
|
type=InterfaceTypeChoices.TYPE_2GFC_SFP,
|
|
340
|
-
status=status,
|
|
381
|
+
status=cls.status,
|
|
341
382
|
)
|
|
342
383
|
cls.vlan = VLAN.objects.first()
|
|
343
384
|
cls.data = {
|
|
344
385
|
"device": cls.device.pk,
|
|
345
386
|
"name": "test interface form 0.0",
|
|
346
387
|
"type": InterfaceTypeChoices.TYPE_2GFC_SFP,
|
|
347
|
-
"status": status.pk,
|
|
388
|
+
"status": cls.status.pk,
|
|
348
389
|
"mode": InterfaceModeChoices.MODE_TAGGED,
|
|
349
390
|
"tagged_vlans": [cls.vlan.pk],
|
|
350
391
|
}
|
|
@@ -394,7 +435,6 @@ class InterfaceTestCase(NautobotTestCaseMixin, TestCase):
|
|
|
394
435
|
Assert that untagged_vlans field dropdown are populated correctly in InterfaceForm and InterfaceBulkEditForm,
|
|
395
436
|
and that the queryset is the same for both forms.
|
|
396
437
|
"""
|
|
397
|
-
status = Status.objects.get_for_model(Interface).first()
|
|
398
438
|
location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
|
|
399
439
|
devices = Device.objects.all()[:3]
|
|
400
440
|
for device in devices:
|
|
@@ -405,19 +445,19 @@ class InterfaceTestCase(NautobotTestCaseMixin, TestCase):
|
|
|
405
445
|
device=devices[0],
|
|
406
446
|
name="Test Interface 1",
|
|
407
447
|
type=InterfaceTypeChoices.TYPE_2GFC_SFP,
|
|
408
|
-
status=status,
|
|
448
|
+
status=self.status,
|
|
409
449
|
),
|
|
410
450
|
Interface.objects.create(
|
|
411
451
|
device=devices[1],
|
|
412
452
|
name="Test Interface 2",
|
|
413
453
|
type=InterfaceTypeChoices.TYPE_LAG,
|
|
414
|
-
status=status,
|
|
454
|
+
status=self.status,
|
|
415
455
|
),
|
|
416
456
|
Interface.objects.create(
|
|
417
457
|
device=devices[2],
|
|
418
458
|
name="Test Interface 3",
|
|
419
459
|
type=InterfaceTypeChoices.TYPE_100ME_FIXED,
|
|
420
|
-
status=status,
|
|
460
|
+
status=self.status,
|
|
421
461
|
),
|
|
422
462
|
)
|
|
423
463
|
edit_form = InterfaceForm(data=self.data, instance=interfaces[0])
|
|
@@ -429,3 +469,65 @@ class InterfaceTestCase(NautobotTestCaseMixin, TestCase):
|
|
|
429
469
|
edit_form.fields["untagged_vlan"].queryset,
|
|
430
470
|
bulk_edit_form.fields["untagged_vlan"].queryset,
|
|
431
471
|
)
|
|
472
|
+
|
|
473
|
+
def test_interface_form_fields_and_blank(self):
|
|
474
|
+
data = {
|
|
475
|
+
"device": self.device.pk,
|
|
476
|
+
"name": self.interface.name,
|
|
477
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
478
|
+
"status": self.status.pk,
|
|
479
|
+
"speed": "", # blank should coerce to None
|
|
480
|
+
"duplex": "", # blank allowed
|
|
481
|
+
}
|
|
482
|
+
form = InterfaceForm(data=data, instance=self.interface)
|
|
483
|
+
self.assertIn("speed", form.fields)
|
|
484
|
+
self.assertIn("duplex", form.fields)
|
|
485
|
+
self.assertTrue(form.is_valid())
|
|
486
|
+
self.assertIsNone(form.cleaned_data["speed"]) # TypedChoiceField(empty->None)
|
|
487
|
+
self.assertEqual(form.cleaned_data["duplex"], "")
|
|
488
|
+
|
|
489
|
+
def test_interface_form_speed_choice_coerces_int(self):
|
|
490
|
+
speed_choice = InterfaceSpeedChoices.SPEED_10G
|
|
491
|
+
data = {
|
|
492
|
+
"device": self.device.pk,
|
|
493
|
+
"name": self.interface.name,
|
|
494
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
495
|
+
"status": self.status.pk,
|
|
496
|
+
# Posted value is a string; TypedChoiceField should coerce to int
|
|
497
|
+
"speed": str(speed_choice),
|
|
498
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_FULL,
|
|
499
|
+
}
|
|
500
|
+
form = InterfaceForm(data=data, instance=self.interface)
|
|
501
|
+
self.assertTrue(form.is_valid())
|
|
502
|
+
self.assertIsInstance(form.cleaned_data["speed"], int)
|
|
503
|
+
self.assertEqual(form.cleaned_data["speed"], speed_choice)
|
|
504
|
+
self.assertEqual(form.cleaned_data["duplex"], InterfaceDuplexChoices.DUPLEX_FULL)
|
|
505
|
+
|
|
506
|
+
def test_interface_create_form_blank_and_choice(self):
|
|
507
|
+
# Blank speed
|
|
508
|
+
data_blank = {
|
|
509
|
+
"device": self.device.pk,
|
|
510
|
+
"name_pattern": "eth1",
|
|
511
|
+
"status": self.status.pk,
|
|
512
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
513
|
+
"speed": "",
|
|
514
|
+
"duplex": "",
|
|
515
|
+
}
|
|
516
|
+
form_blank = InterfaceCreateForm(data_blank)
|
|
517
|
+
self.assertTrue(form_blank.is_valid())
|
|
518
|
+
self.assertIsNone(form_blank.cleaned_data["speed"]) # TypedChoiceField(empty->None)
|
|
519
|
+
|
|
520
|
+
# With a specific choice
|
|
521
|
+
speed_choice = InterfaceSpeedChoices.SPEED_1G
|
|
522
|
+
data_choice = {
|
|
523
|
+
"device": self.device.pk,
|
|
524
|
+
"name_pattern": "eth2",
|
|
525
|
+
"status": self.status.pk,
|
|
526
|
+
"type": InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
527
|
+
"speed": str(speed_choice),
|
|
528
|
+
"duplex": InterfaceDuplexChoices.DUPLEX_AUTO,
|
|
529
|
+
}
|
|
530
|
+
form_choice = InterfaceCreateForm(data_choice)
|
|
531
|
+
self.assertTrue(form_choice.is_valid())
|
|
532
|
+
self.assertEqual(form_choice.cleaned_data["speed"], speed_choice)
|
|
533
|
+
self.assertEqual(form_choice.cleaned_data["duplex"], InterfaceDuplexChoices.DUPLEX_AUTO)
|