nautobot 2.4.21__py3-none-any.whl → 2.4.23__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.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (62) hide show
  1. nautobot/apps/choices.py +4 -0
  2. nautobot/apps/utils.py +8 -0
  3. nautobot/circuits/views.py +6 -2
  4. nautobot/core/cli/migrate_deprecated_templates.py +28 -9
  5. nautobot/core/filters.py +4 -0
  6. nautobot/core/forms/__init__.py +2 -0
  7. nautobot/core/forms/widgets.py +21 -2
  8. nautobot/core/jobs/bulk_actions.py +12 -6
  9. nautobot/core/jobs/cleanup.py +13 -1
  10. nautobot/core/settings.py +6 -0
  11. nautobot/core/settings_funcs.py +11 -1
  12. nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
  13. nautobot/core/templatetags/helpers.py +9 -7
  14. nautobot/core/tests/nautobot_config.py +3 -0
  15. nautobot/core/tests/test_jobs.py +118 -0
  16. nautobot/core/tests/test_templatetags_helpers.py +6 -0
  17. nautobot/core/tests/test_ui.py +49 -1
  18. nautobot/core/tests/test_utils.py +41 -1
  19. nautobot/core/ui/object_detail.py +7 -2
  20. nautobot/core/urls.py +7 -8
  21. nautobot/core/utils/filtering.py +11 -1
  22. nautobot/core/utils/lookup.py +46 -0
  23. nautobot/core/views/mixins.py +23 -17
  24. nautobot/core/views/utils.py +3 -3
  25. nautobot/dcim/api/serializers.py +3 -0
  26. nautobot/dcim/choices.py +49 -0
  27. nautobot/dcim/constants.py +7 -0
  28. nautobot/dcim/filters/__init__.py +7 -0
  29. nautobot/dcim/forms.py +89 -3
  30. nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
  31. nautobot/dcim/models/device_component_templates.py +33 -1
  32. nautobot/dcim/models/device_components.py +21 -0
  33. nautobot/dcim/tables/devices.py +14 -0
  34. nautobot/dcim/tables/devicetypes.py +8 -1
  35. nautobot/dcim/templates/dcim/interface.html +8 -0
  36. nautobot/dcim/templates/dcim/interface_edit.html +2 -0
  37. nautobot/dcim/tests/test_api.py +186 -6
  38. nautobot/dcim/tests/test_filters.py +32 -0
  39. nautobot/dcim/tests/test_forms.py +110 -8
  40. nautobot/dcim/tests/test_graphql.py +44 -1
  41. nautobot/dcim/tests/test_models.py +265 -0
  42. nautobot/dcim/tests/test_tables.py +160 -0
  43. nautobot/dcim/tests/test_views.py +64 -1
  44. nautobot/dcim/views.py +86 -77
  45. nautobot/extras/forms/forms.py +3 -1
  46. nautobot/extras/jobs.py +48 -2
  47. nautobot/extras/models/models.py +19 -0
  48. nautobot/extras/models/relationships.py +3 -1
  49. nautobot/extras/templates/extras/plugin_detail.html +2 -2
  50. nautobot/extras/urls.py +0 -14
  51. nautobot/extras/views.py +1 -1
  52. nautobot/ipam/ui.py +0 -17
  53. nautobot/ipam/views.py +2 -2
  54. nautobot/project-static/js/forms.js +92 -14
  55. nautobot/virtualization/tests/test_models.py +4 -2
  56. nautobot/virtualization/views.py +1 -0
  57. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/METADATA +4 -4
  58. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/RECORD +62 -59
  59. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/LICENSE.txt +0 -0
  60. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/NOTICE +0 -0
  61. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/WHEEL +0 -0
  62. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/entry_points.txt +0 -0
@@ -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="1000base-t",
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="1000base-t",
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": "1000base-t",
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": "1000base-t",
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": "virtual",
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 DeviceFaceChoices, InterfaceModeChoices, InterfaceTypeChoices, RackWidthChoices
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)
@@ -3,7 +3,7 @@ from django.test import override_settings
3
3
 
4
4
  from nautobot.core.graphql import execute_query
5
5
  from nautobot.core.testing import create_test_user, TestCase
6
- from nautobot.dcim.choices import InterfaceTypeChoices
6
+ from nautobot.dcim.choices import InterfaceDuplexChoices, InterfaceSpeedChoices, InterfaceTypeChoices
7
7
  from nautobot.dcim.models import (
8
8
  Controller,
9
9
  Device,
@@ -52,6 +52,22 @@ class GraphQLTestCase(TestCase):
52
52
  type=InterfaceTypeChoices.TYPE_VIRTUAL,
53
53
  mac_address=None,
54
54
  ),
55
+ Interface.objects.create(
56
+ device=self.device,
57
+ name="eth2",
58
+ status=interface_status,
59
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
60
+ speed=InterfaceSpeedChoices.SPEED_1G,
61
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
62
+ ),
63
+ Interface.objects.create(
64
+ device=self.device,
65
+ name="eth3",
66
+ status=interface_status,
67
+ type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
68
+ speed=InterfaceSpeedChoices.SPEED_10G,
69
+ duplex="",
70
+ ),
55
71
  )
56
72
  for interface in self.interfaces:
57
73
  interface.validated_save()
@@ -135,3 +151,30 @@ class GraphQLTestCase(TestCase):
135
151
  self.assertFalse(resp["data"].get("error"))
136
152
  for device in resp["data"]["devices"]:
137
153
  self.assertNotEqual(device["serial"], "")
154
+
155
+ with self.subTest("interface speed/duplex fields on device query"):
156
+ query = "query { devices { name interfaces { name speed duplex } } }"
157
+ resp = execute_query(query, user=self.user).to_dict()
158
+ self.assertFalse(resp["data"].get("error"))
159
+ interfaces = [i for d in resp["data"]["devices"] if d["name"] == self.device.name for i in d["interfaces"]]
160
+ eth2 = next(i for i in interfaces if i["name"] == "eth2")
161
+ eth3 = next(i for i in interfaces if i["name"] == "eth3")
162
+ self.assertEqual(eth2["speed"], InterfaceSpeedChoices.SPEED_1G)
163
+ self.assertEqual(eth2["duplex"].lower(), InterfaceDuplexChoices.DUPLEX_FULL)
164
+ self.assertEqual(eth3["speed"], InterfaceSpeedChoices.SPEED_10G)
165
+ self.assertEqual(eth3["duplex"], None)
166
+
167
+ with self.subTest("interfaces root filter by speed and duplex"):
168
+ query = f"query {{ interfaces(speed: {InterfaceSpeedChoices.SPEED_1G}) {{ name }} }}"
169
+ resp = execute_query(query, user=self.user).to_dict()
170
+ self.assertFalse(resp["data"].get("error"))
171
+ names = {i["name"] for i in resp["data"]["interfaces"]}
172
+ self.assertIn("eth2", names)
173
+ self.assertNotIn("eth3", names)
174
+
175
+ query = 'query { interfaces(duplex: ["full"]) { name } }'
176
+ resp = execute_query(query, user=self.user).to_dict()
177
+ self.assertFalse(resp["data"].get("error"))
178
+ names = {i["name"] for i in resp["data"]["interfaces"]}
179
+ self.assertIn("eth2", names)
180
+ self.assertNotIn("eth3", names)