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
@@ -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)
@@ -16,7 +16,9 @@ from nautobot.dcim.choices import (
16
16
  CableTypeChoices,
17
17
  ConsolePortTypeChoices,
18
18
  DeviceFaceChoices,
19
+ InterfaceDuplexChoices,
19
20
  InterfaceModeChoices,
21
+ InterfaceSpeedChoices,
20
22
  InterfaceTypeChoices,
21
23
  PortTypeChoices,
22
24
  PowerFeedBreakerPoleChoices,
@@ -724,6 +726,101 @@ class InterfaceTemplateTestCase(ModularDeviceComponentTemplateTestCaseMixin, Tes
724
726
  first_status = Status.objects.get_for_model(Interface).first()
725
727
  self.assertIsNotNone(device_2.interfaces.get(name="Test_Template_1").status, first_status)
726
728
 
729
+ def test_speed_disallowed_for_lag_virtual_wireless(self):
730
+ """speed must be None for LAG, virtual, and wireless templates."""
731
+ manufacturer = Manufacturer.objects.first()
732
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="SpeedGuard 1000")
733
+
734
+ for if_type in (
735
+ InterfaceTypeChoices.TYPE_LAG,
736
+ InterfaceTypeChoices.TYPE_VIRTUAL,
737
+ InterfaceTypeChoices.TYPE_80211N,
738
+ ):
739
+ with self.subTest(if_type=if_type):
740
+ with self.assertRaises(ValidationError) as cm:
741
+ InterfaceTemplate(
742
+ device_type=device_type,
743
+ name=f"bad-{if_type}",
744
+ type=if_type,
745
+ speed=InterfaceSpeedChoices.SPEED_1G,
746
+ ).full_clean()
747
+ self.assertIn("Speed is not applicable to this interface type.", str(cm.exception))
748
+
749
+ def test_duplex_disallowed_for_lag_virtual_wireless(self):
750
+ """duplex must be blank for LAG, virtual, and wireless templates."""
751
+ manufacturer = Manufacturer.objects.first()
752
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="DuplexGuard 1000")
753
+
754
+ for itype in (
755
+ InterfaceTypeChoices.TYPE_LAG,
756
+ InterfaceTypeChoices.TYPE_VIRTUAL,
757
+ InterfaceTypeChoices.TYPE_80211N,
758
+ ):
759
+ with self.assertRaises(ValidationError):
760
+ InterfaceTemplate(
761
+ device_type=device_type,
762
+ name=f"bad-{itype}",
763
+ type=itype,
764
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
765
+ ).full_clean()
766
+
767
+ def test_duplex_disallowed_for_non_base_t(self):
768
+ """duplex must be blank for non-BASE-T physical types (e.g., SFP)."""
769
+ manufacturer = Manufacturer.objects.first()
770
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="SfpGuard 1000")
771
+
772
+ with self.assertRaises(ValidationError) as cm:
773
+ InterfaceTemplate(
774
+ device_type=device_type,
775
+ name="sfp0",
776
+ type=InterfaceTypeChoices.TYPE_1GE_SFP,
777
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
778
+ ).full_clean()
779
+ self.assertIn("Duplex is only applicable to copper twisted-pair interfaces.", str(cm.exception))
780
+
781
+ def test_duplex_and_speed_allowed_for_base_t(self):
782
+ """BASE-T physical types accept duplex and speed values."""
783
+ manufacturer = Manufacturer.objects.first()
784
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="CopperOK 1000")
785
+
786
+ tmpl = InterfaceTemplate(
787
+ device_type=device_type,
788
+ name="eth0",
789
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
790
+ speed=InterfaceSpeedChoices.SPEED_1G,
791
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
792
+ )
793
+ tmpl.full_clean() # should not raise
794
+
795
+ def test_instantiation_propagates_speed_and_duplex(self):
796
+ """Interface created from template inherits speed and duplex."""
797
+ statuses = Status.objects.get_for_model(Device)
798
+ location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
799
+ manufacturer = Manufacturer.objects.first()
800
+ device_role = Role.objects.get_for_model(Device).first()
801
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="Propagate 2000")
802
+
803
+ InterfaceTemplate.objects.create(
804
+ device_type=device_type,
805
+ name="EthX",
806
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
807
+ mgmt_only=False,
808
+ speed=InterfaceSpeedChoices.SPEED_1G,
809
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
810
+ )
811
+
812
+ device = Device.objects.create(
813
+ device_type=device_type,
814
+ role=device_role,
815
+ status=statuses[0],
816
+ name="Device-Prop",
817
+ location=location,
818
+ )
819
+
820
+ iface = device.interfaces.get(name="EthX")
821
+ self.assertEqual(iface.speed, InterfaceSpeedChoices.SPEED_1G)
822
+ self.assertEqual(iface.duplex, InterfaceDuplexChoices.DUPLEX_FULL)
823
+
727
824
 
728
825
  class InterfaceRedundancyGroupTestCase(ModelTestCases.BaseModelTestCase):
729
826
  model = InterfaceRedundancyGroup
@@ -2695,6 +2792,7 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2695
2792
  name="VLAN 1", vid=100, location=location, status=vlan_status, vlan_group=vlan_group
2696
2793
  )
2697
2794
  status = Status.objects.get_for_model(Device).first()
2795
+ cls.intf_status = Status.objects.get_for_model(Interface).first()
2698
2796
  cls.device = Device.objects.create(
2699
2797
  name="Device 1",
2700
2798
  device_type=devicetype,
@@ -2924,6 +3022,173 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2924
3022
  self.device.refresh_from_db()
2925
3023
  self.assertEqual(self.device.primary_ip6, None)
2926
3024
 
3025
+ def _assert_invalid_speed_duplex(self, if_type, speed=None, duplex="", expected_error=""):
3026
+ iface = Interface(
3027
+ device=self.device,
3028
+ name=f"test-{if_type}",
3029
+ type=if_type,
3030
+ status=self.intf_status,
3031
+ speed=speed,
3032
+ duplex=duplex,
3033
+ )
3034
+ with self.assertRaises(ValidationError) as cm:
3035
+ iface.full_clean()
3036
+ self.assertIn(expected_error, str(cm.exception))
3037
+
3038
+ def test_disallowed_speed_and_duplex_matrix(self):
3039
+ """Test that interface types with no speed or duplex disallow those settings."""
3040
+ test_cases = [
3041
+ # LAG
3042
+ (
3043
+ InterfaceTypeChoices.TYPE_LAG,
3044
+ InterfaceSpeedChoices.SPEED_1M,
3045
+ None,
3046
+ "Speed is not applicable to this interface type.",
3047
+ ),
3048
+ (
3049
+ InterfaceTypeChoices.TYPE_LAG,
3050
+ None,
3051
+ InterfaceDuplexChoices.DUPLEX_FULL,
3052
+ "Duplex is not applicable to this interface type.",
3053
+ ),
3054
+ # Virtual
3055
+ (
3056
+ InterfaceTypeChoices.TYPE_VIRTUAL,
3057
+ InterfaceSpeedChoices.SPEED_1M,
3058
+ None,
3059
+ "Speed is not applicable to this interface type.",
3060
+ ),
3061
+ (
3062
+ InterfaceTypeChoices.TYPE_VIRTUAL,
3063
+ None,
3064
+ InterfaceDuplexChoices.DUPLEX_FULL,
3065
+ "Duplex is not applicable to this interface type.",
3066
+ ),
3067
+ # Wireless
3068
+ (
3069
+ InterfaceTypeChoices.TYPE_80211AC,
3070
+ InterfaceSpeedChoices.SPEED_1M,
3071
+ None,
3072
+ "Speed is not applicable to this interface type.",
3073
+ ),
3074
+ (
3075
+ InterfaceTypeChoices.TYPE_80211AC,
3076
+ None,
3077
+ InterfaceDuplexChoices.DUPLEX_FULL,
3078
+ "Duplex is not applicable to this interface type.",
3079
+ ),
3080
+ # Copper (negative speed is invalid)
3081
+ (InterfaceTypeChoices.TYPE_1GE_FIXED, -100, None, "Ensure this value is greater than or equal to 0."),
3082
+ # Copper (speed as a string is invalid)
3083
+ (InterfaceTypeChoices.TYPE_1GE_FIXED, "100 Mbps", None, "value must be an integer."),
3084
+ # Copper (invalid duplex is invalid)
3085
+ (
3086
+ InterfaceTypeChoices.TYPE_1GE_FIXED,
3087
+ InterfaceSpeedChoices.SPEED_1M,
3088
+ "invalid",
3089
+ "Value 'invalid' is not a valid choice.",
3090
+ ),
3091
+ # Optical (no duplex allowed)
3092
+ (
3093
+ InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
3094
+ InterfaceSpeedChoices.SPEED_1M,
3095
+ InterfaceDuplexChoices.DUPLEX_FULL,
3096
+ "Duplex is only applicable to copper twisted-pair interfaces.",
3097
+ ),
3098
+ ]
3099
+ for if_type, speed, duplex, expected_error in test_cases:
3100
+ with self.subTest(f"{if_type} with speed={speed} and duplex={duplex}"):
3101
+ self._assert_invalid_speed_duplex(if_type, speed, duplex, expected_error)
3102
+
3103
+ def test_copper_allows_duplex_and_non_negative_speed(self):
3104
+ """Test that copper interfaces allow duplex and non-negative speed."""
3105
+ iface = Interface(
3106
+ device=self.device,
3107
+ name="eth1",
3108
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED, # 1000BASE-T
3109
+ status=self.intf_status,
3110
+ speed=InterfaceSpeedChoices.SPEED_1G,
3111
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
3112
+ )
3113
+ # Should not raise
3114
+ iface.full_clean()
3115
+
3116
+ iface.speed = 0
3117
+ iface.full_clean()
3118
+
3119
+ def test_lag_allows_no_speed_or_duplex(self):
3120
+ """Test that LAG interfaces pass validation when speed and duplex are not set."""
3121
+ iface = Interface(
3122
+ device=self.device,
3123
+ name="Port-Channel1",
3124
+ type=InterfaceTypeChoices.TYPE_LAG,
3125
+ status=self.intf_status,
3126
+ )
3127
+ # Should not raise when speed and duplex are not set
3128
+ iface.full_clean()
3129
+
3130
+ def test_optical_disallows_duplex_allows_speed(self):
3131
+ """Test that optical interfaces do not allow duplex and allow positive speed."""
3132
+ # Duplex set should error
3133
+ iface_bad = Interface(
3134
+ device=self.device,
3135
+ name="xe0",
3136
+ type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
3137
+ status=self.intf_status,
3138
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
3139
+ )
3140
+ with self.assertRaises(ValidationError) as cm:
3141
+ iface_bad.full_clean()
3142
+ self.assertIn("Duplex is only applicable to copper twisted-pair interfaces.", str(cm.exception))
3143
+
3144
+ # Speed positive should pass
3145
+ iface_ok = Interface(
3146
+ device=self.device,
3147
+ name="xe1",
3148
+ type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
3149
+ status=self.intf_status,
3150
+ speed=InterfaceSpeedChoices.SPEED_10G,
3151
+ )
3152
+ iface_ok.full_clean()
3153
+
3154
+ def test_changing_copper_interface_with_speed_and_duplex_to_optical_fails(self):
3155
+ """Test that changing a copper interface with speed and duplex to an optical interface fails."""
3156
+
3157
+ with self.subTest("speed"):
3158
+ iface = Interface(
3159
+ device=self.device,
3160
+ name="eth3",
3161
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
3162
+ status=self.intf_status,
3163
+ speed=InterfaceSpeedChoices.SPEED_1G,
3164
+ )
3165
+ iface.full_clean()
3166
+
3167
+ iface.type = InterfaceTypeChoices.TYPE_LAG
3168
+ with self.assertRaises(ValidationError) as cm:
3169
+ iface.full_clean()
3170
+ self.assertIn("Speed is not applicable to this interface type.", str(cm.exception))
3171
+
3172
+ with self.subTest("duplex"):
3173
+ iface = Interface(
3174
+ device=self.device,
3175
+ name="eth3",
3176
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
3177
+ status=self.intf_status,
3178
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
3179
+ )
3180
+ iface.full_clean()
3181
+
3182
+ iface.type = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
3183
+ with self.assertRaises(ValidationError) as cm:
3184
+ iface.full_clean()
3185
+ self.assertIn("Duplex is only applicable to copper twisted-pair interfaces.", str(cm.exception))
3186
+
3187
+ iface.type = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
3188
+ with self.assertRaises(ValidationError) as cm:
3189
+ iface.full_clean()
3190
+ self.assertIn("Duplex is only applicable to copper twisted-pair interfaces.", str(cm.exception))
3191
+
2927
3192
 
2928
3193
  class SoftwareImageFileTestCase(ModelTestCases.BaseModelTestCase):
2929
3194
  model = SoftwareImageFile
@@ -0,0 +1,160 @@
1
+ from django.test import TestCase
2
+
3
+ from nautobot.dcim.choices import InterfaceDuplexChoices, InterfaceSpeedChoices, InterfaceTypeChoices
4
+ from nautobot.dcim.models import Device, DeviceType, Interface, InterfaceTemplate, Location, LocationType, Manufacturer
5
+ from nautobot.dcim.tables.devices import DeviceModuleInterfaceTable, InterfaceTable
6
+ from nautobot.dcim.tables.devicetypes import InterfaceTemplateTable
7
+ from nautobot.extras.models import Role, Status
8
+
9
+
10
+ class InterfaceTableRenderMixin:
11
+ """Mixin for testing render_speed methods on interface tables."""
12
+
13
+ table_class = None
14
+
15
+ @classmethod
16
+ def setUpTestData(cls):
17
+ manufacturer = Manufacturer.objects.create(name="Test Manufacturer")
18
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model="Test Device Type")
19
+ device_role = Role.objects.get_for_model(Device).first()
20
+ location_type = LocationType.objects.get(name="Campus")
21
+ location = Location.objects.filter(location_type=location_type).first()
22
+ device_status = Status.objects.get_for_model(Device).first()
23
+ cls.interface_status = Status.objects.get_for_model(Interface).first()
24
+
25
+ cls.device = Device.objects.create(
26
+ name="Test Device",
27
+ device_type=device_type,
28
+ role=device_role,
29
+ location=location,
30
+ status=device_status,
31
+ )
32
+
33
+ def test_render_speed_duplex_with_value(self):
34
+ """Test that the table renders humanized speed values."""
35
+ interface = Interface.objects.create(
36
+ device=self.device,
37
+ name="eth0",
38
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
39
+ status=self.interface_status,
40
+ speed=InterfaceSpeedChoices.SPEED_1G,
41
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
42
+ )
43
+
44
+ queryset = Interface.objects.filter(pk=interface.pk)
45
+ table = self.table_class(queryset) # pylint: disable=not-callable
46
+ bound_row = table.rows[0]
47
+ rendered_speed = bound_row.get_cell("speed")
48
+ rendered_duplex = bound_row.get_cell("duplex")
49
+
50
+ self.assertEqual(rendered_speed, "1 Gbps")
51
+ self.assertEqual(rendered_duplex, "Full")
52
+
53
+ def test_render_speed_duplex_with_none(self):
54
+ """Test that the table handles None speed value and renders an emdash."""
55
+ emdash = "\u2014"
56
+ interface = Interface.objects.create(
57
+ device=self.device,
58
+ name="eth1",
59
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
60
+ status=self.interface_status,
61
+ speed=None,
62
+ )
63
+
64
+ queryset = Interface.objects.filter(pk=interface.pk)
65
+ table = self.table_class(queryset) # pylint: disable=not-callable
66
+ bound_row = table.rows[0]
67
+ rendered_speed = bound_row.get_cell("speed")
68
+ rendered_duplex = bound_row.get_cell("duplex")
69
+
70
+ self.assertEqual(rendered_speed, emdash)
71
+ self.assertEqual(rendered_duplex, emdash)
72
+
73
+ def test_render_speed_various(self):
74
+ """Test that the table correctly humanizes various speed values."""
75
+ # Test all speed choices defined in InterfaceSpeedChoices
76
+ for speed_value, expected_output in InterfaceSpeedChoices.CHOICES:
77
+ with self.subTest(speed_value=speed_value, expected=expected_output):
78
+ interface = Interface.objects.create(
79
+ device=self.device,
80
+ name=f"eth-{speed_value}",
81
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
82
+ status=self.interface_status,
83
+ speed=speed_value,
84
+ )
85
+
86
+ queryset = Interface.objects.filter(pk=interface.pk)
87
+ table = self.table_class(queryset) # pylint: disable=not-callable
88
+ bound_row = table.rows[0]
89
+ rendered_speed = bound_row.get_cell("speed")
90
+
91
+ self.assertEqual(rendered_speed, expected_output)
92
+
93
+ def test_render_duplex_various(self):
94
+ """Test that the table correctly renders various duplex values."""
95
+ for duplex_value, expected_output in InterfaceDuplexChoices.CHOICES:
96
+ with self.subTest(duplex_value=duplex_value, expected=expected_output):
97
+ interface = Interface.objects.create(
98
+ device=self.device,
99
+ name=f"eth-{duplex_value}",
100
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
101
+ status=self.interface_status,
102
+ duplex=duplex_value,
103
+ )
104
+
105
+ queryset = Interface.objects.filter(pk=interface.pk)
106
+ table = self.table_class(queryset) # pylint: disable=not-callable
107
+ bound_row = table.rows[0]
108
+ rendered_duplex = bound_row.get_cell("duplex")
109
+
110
+ self.assertEqual(rendered_duplex, expected_output)
111
+
112
+
113
+ class InterfaceTableTestCase(InterfaceTableRenderMixin, TestCase):
114
+ """Test cases for InterfaceTable."""
115
+
116
+ table_class = InterfaceTable
117
+
118
+
119
+ class DeviceModuleInterfaceTableTestCase(InterfaceTableRenderMixin, TestCase):
120
+ """Test cases for DeviceModuleInterfaceTable."""
121
+
122
+ table_class = DeviceModuleInterfaceTable
123
+
124
+
125
+ class InterfaceTemplateTableTestCase(TestCase):
126
+ """Render tests for InterfaceTemplateTable speed/duplex columns."""
127
+
128
+ @classmethod
129
+ def setUpTestData(cls):
130
+ manufacturer = Manufacturer.objects.create(name="Test Manuf Tmpl")
131
+ cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model="DT-Tmpl")
132
+
133
+ def test_render_speed_duplex_with_value(self):
134
+ interface_template = InterfaceTemplate.objects.create(
135
+ device_type=self.device_type,
136
+ name="tmpl-eth0",
137
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
138
+ speed=InterfaceSpeedChoices.SPEED_1G,
139
+ duplex=InterfaceDuplexChoices.DUPLEX_FULL,
140
+ )
141
+ table = InterfaceTemplateTable(InterfaceTemplate.objects.filter(pk=interface_template.pk))
142
+ bound_row = table.rows[0]
143
+ rendered_speed = bound_row.get_cell("speed") # pylint: disable=no-member
144
+ rendered_duplex = bound_row.get_cell("duplex") # pylint: disable=no-member
145
+ self.assertEqual(rendered_speed, "1 Gbps")
146
+ self.assertEqual(rendered_duplex, "Full")
147
+
148
+ def test_render_speed_duplex_with_none(self):
149
+ emdash = "\u2014"
150
+ interface_template = InterfaceTemplate.objects.create(
151
+ device_type=self.device_type,
152
+ name="tmpl-eth1",
153
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
154
+ )
155
+ table = InterfaceTemplateTable(InterfaceTemplate.objects.filter(pk=interface_template.pk))
156
+ bound_row = table.rows[0]
157
+ rendered_speed = bound_row.get_cell("speed") # pylint: disable=no-member
158
+ rendered_duplex = bound_row.get_cell("duplex") # pylint: disable=no-member
159
+ self.assertEqual(rendered_speed, emdash)
160
+ self.assertEqual(rendered_duplex, emdash)
@@ -30,6 +30,7 @@ from nautobot.dcim.choices import (
30
30
  ConsolePortTypeChoices,
31
31
  DeviceFaceChoices,
32
32
  DeviceRedundancyGroupFailoverStrategyChoices,
33
+ InterfaceDuplexChoices,
33
34
  InterfaceModeChoices,
34
35
  InterfaceRedundancyGroupProtocolChoices,
35
36
  InterfaceTypeChoices,
@@ -1837,6 +1838,68 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
1837
1838
  "description": "new test description",
1838
1839
  }
1839
1840
 
1841
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1842
+ def test_create_base_t_with_speed_and_duplex(self):
1843
+ self.add_permissions("dcim.add_interfacetemplate", "dcim.view_devicetype")
1844
+ url = reverse("dcim:interfacetemplate_add")
1845
+ dt = DeviceType.objects.first()
1846
+ data = {
1847
+ "device_type": dt.pk,
1848
+ "name_pattern": "Eth-View-1",
1849
+ "type": InterfaceTypeChoices.TYPE_1GE_FIXED,
1850
+ "mgmt_only": False,
1851
+ "speed": 1_000_000,
1852
+ "duplex": InterfaceDuplexChoices.DUPLEX_FULL,
1853
+ "_create": True,
1854
+ }
1855
+ response = self.client.post(url, data)
1856
+
1857
+ # Successful create redirects (JobResult or object list)
1858
+ self.assertIn(response.status_code, (302, 303))
1859
+ interface_template = InterfaceTemplate.objects.get(name="Eth-View-1")
1860
+ self.assertEqual(interface_template.speed, 1_000_000)
1861
+ self.assertEqual(interface_template.duplex, InterfaceDuplexChoices.DUPLEX_FULL)
1862
+
1863
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1864
+ def test_create_sfp_with_duplex_rejected(self):
1865
+ self.add_permissions("dcim.add_interfacetemplate", "dcim.view_devicetype")
1866
+ url = reverse("dcim:interfacetemplate_add")
1867
+ dt = DeviceType.objects.first()
1868
+ data = {
1869
+ "device_type": dt.pk,
1870
+ "name_pattern": "SFP-View-1",
1871
+ "type": InterfaceTypeChoices.TYPE_1GE_SFP,
1872
+ "duplex": InterfaceDuplexChoices.DUPLEX_FULL,
1873
+ "_create": True,
1874
+ }
1875
+ response = self.client.post(url, data)
1876
+ # Form error returns 200 with field error displayed
1877
+ self.assertEqual(response.status_code, 200)
1878
+ content = response.content.decode(response.charset)
1879
+ self.assertIn("Duplex is only applicable to copper twisted-pair interfaces.", content)
1880
+
1881
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1882
+ def test_bulk_create_with_speed_and_duplex(self):
1883
+ self.add_permissions("dcim.add_interfacetemplate", "dcim.view_devicetype")
1884
+ url = reverse("dcim:interfacetemplate_add")
1885
+ dt = DeviceType.objects.first()
1886
+ data = {
1887
+ "device_type": dt.pk,
1888
+ "name_pattern": "Et[1-2]",
1889
+ "type": InterfaceTypeChoices.TYPE_1GE_FIXED,
1890
+ "mgmt_only": False,
1891
+ "speed": 1_000_000,
1892
+ "duplex": InterfaceDuplexChoices.DUPLEX_FULL,
1893
+ "_apply": True,
1894
+ }
1895
+ response = self.client.post(url, data)
1896
+ self.assertIn(response.status_code, (302, 303))
1897
+ objs = InterfaceTemplate.objects.filter(name__in=["Et1", "Et2"]).order_by("name")
1898
+ self.assertEqual(objs.count(), 2)
1899
+ for obj in objs:
1900
+ self.assertEqual(obj.speed, 1_000_000)
1901
+ self.assertEqual(obj.duplex, InterfaceDuplexChoices.DUPLEX_FULL)
1902
+
1840
1903
 
1841
1904
  class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
1842
1905
  model = FrontPortTemplate
@@ -4811,7 +4874,7 @@ class ControllerTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4811
4874
  model = Controller
4812
4875
  filterset = ControllerFilterSet
4813
4876
  custom_action_required_permissions = {
4814
- "dcim:controller_wirelessnetworks": [
4877
+ "dcim:controller_wireless_networks": [
4815
4878
  "dcim.view_controller",
4816
4879
  "wireless.view_controllermanageddevicegroupwirelessnetworkassignment",
4817
4880
  ],