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.
Files changed (54) 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/settings.py +6 -0
  9. nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
  10. nautobot/core/templatetags/helpers.py +9 -7
  11. nautobot/core/tests/nautobot_config.py +3 -0
  12. nautobot/core/tests/test_templatetags_helpers.py +6 -0
  13. nautobot/core/tests/test_ui.py +49 -1
  14. nautobot/core/tests/test_utils.py +41 -1
  15. nautobot/core/ui/object_detail.py +7 -2
  16. nautobot/core/urls.py +7 -8
  17. nautobot/core/utils/filtering.py +11 -1
  18. nautobot/core/utils/lookup.py +46 -0
  19. nautobot/core/views/mixins.py +21 -16
  20. nautobot/dcim/api/serializers.py +3 -0
  21. nautobot/dcim/choices.py +49 -0
  22. nautobot/dcim/constants.py +7 -0
  23. nautobot/dcim/filters/__init__.py +7 -0
  24. nautobot/dcim/forms.py +89 -3
  25. nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
  26. nautobot/dcim/models/device_component_templates.py +33 -1
  27. nautobot/dcim/models/device_components.py +21 -0
  28. nautobot/dcim/tables/devices.py +14 -0
  29. nautobot/dcim/tables/devicetypes.py +8 -1
  30. nautobot/dcim/templates/dcim/interface.html +8 -0
  31. nautobot/dcim/templates/dcim/interface_edit.html +2 -0
  32. nautobot/dcim/tests/test_api.py +186 -6
  33. nautobot/dcim/tests/test_filters.py +32 -0
  34. nautobot/dcim/tests/test_forms.py +110 -8
  35. nautobot/dcim/tests/test_graphql.py +44 -1
  36. nautobot/dcim/tests/test_models.py +265 -0
  37. nautobot/dcim/tests/test_tables.py +160 -0
  38. nautobot/dcim/tests/test_views.py +64 -1
  39. nautobot/dcim/views.py +86 -77
  40. nautobot/extras/forms/forms.py +3 -1
  41. nautobot/extras/templates/extras/plugin_detail.html +2 -2
  42. nautobot/extras/urls.py +0 -14
  43. nautobot/extras/views.py +1 -1
  44. nautobot/ipam/ui.py +0 -17
  45. nautobot/ipam/views.py +2 -2
  46. nautobot/project-static/js/forms.js +92 -14
  47. nautobot/virtualization/tests/test_models.py +4 -2
  48. nautobot/virtualization/views.py +1 -0
  49. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/METADATA +4 -4
  50. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/RECORD +54 -51
  51. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/LICENSE.txt +0 -0
  52. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/NOTICE +0 -0
  53. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/WHEEL +0 -0
  54. {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/entry_points.txt +0 -0
@@ -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 %}
@@ -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)