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
@@ -13,6 +13,14 @@ from django.utils.module_loading import import_string
13
13
  from django.views.generic.base import RedirectView
14
14
 
15
15
 
16
+ def get_breadcrumbs_for_model(model, view_type: str = "List"):
17
+ """Get a UI Component Framework 'Breadcrumbs' instance for the given model's related UIViewSet or generic view."""
18
+ view = get_view_for_model(model)
19
+ if hasattr(view, "get_breadcrumbs"):
20
+ return view.get_breadcrumbs(model, view_type=view_type)
21
+ return None
22
+
23
+
16
24
  def get_changes_for_model(model):
17
25
  """
18
26
  Return a queryset of ObjectChanges for a model or instance. The queryset will be filtered
@@ -30,6 +38,30 @@ def get_changes_for_model(model):
30
38
  raise TypeError(f"{model!r} is not a Django Model class or instance")
31
39
 
32
40
 
41
+ def get_detail_view_components_context_for_model(model) -> dict:
42
+ """Helper method for DistinctViewTabs etc. to retrieve the UI Component Framework context for the base detail view.
43
+
44
+ Functionally equivalent to calling `get_breadcrumbs_for_model()`, `get_object_detail_content_for_model()`, and
45
+ `get_view_titles_for_model()`, but marginally more efficient.
46
+ """
47
+ context = {
48
+ "breadcrumbs": None,
49
+ "object_detail_content": None,
50
+ "view_titles": None,
51
+ }
52
+
53
+ view = get_view_for_model(model, view_type="")
54
+ if view is not None:
55
+ if hasattr(view, "get_breadcrumbs"):
56
+ context["breadcrumbs"] = view.get_breadcrumbs(model, view_type="")
57
+ if hasattr(view, "get_view_titles"):
58
+ context["view_titles"] = view.get_view_titles(model, view_type="")
59
+ if hasattr(view, "object_detail_content"):
60
+ context["object_detail_content"] = view.object_detail_content
61
+
62
+ return context
63
+
64
+
33
65
  def get_model_from_name(model_name):
34
66
  """Given a full model name in dotted format (example: `dcim.model`), a model class is returned if valid.
35
67
 
@@ -178,6 +210,12 @@ def get_form_for_model(model, form_prefix=""):
178
210
  return get_related_class_for_model(model, module_name="forms", object_suffix=object_suffix)
179
211
 
180
212
 
213
+ def get_object_detail_content_for_model(model):
214
+ """Get the UI Component Framework 'object_detail_content' for the given model's related UIViewSet or ObjectView."""
215
+ view = get_view_for_model(model)
216
+ return getattr(view, "object_detail_content", None)
217
+
218
+
181
219
  def get_related_field_for_models(from_model, to_model):
182
220
  """
183
221
  Find the field on `from_model` that is a relation to `to_model`.
@@ -245,6 +283,14 @@ def get_view_for_model(model, view_type=""):
245
283
  return result
246
284
 
247
285
 
286
+ def get_view_titles_for_model(model, view_type: str = "List"):
287
+ """Get a UI Component Framework 'Titles' instance for the given model's related UIViewSet or generic view."""
288
+ view = get_view_for_model(model)
289
+ if hasattr(view, "get_view_titles"):
290
+ return view.get_view_titles(model, view_type=view_type)
291
+ return None
292
+
293
+
248
294
  def get_model_for_view_name(view_name):
249
295
  """
250
296
  Return the model class associated with the given view_name e.g. "circuits:circuit_detail", "dcim:device_list" and etc.
@@ -260,52 +260,57 @@ class UIComponentsMixin:
260
260
  breadcrumbs: ClassVar[Optional[Breadcrumbs]] = None
261
261
  view_titles: ClassVar[Optional[Titles]] = None
262
262
 
263
- def get_view_titles(self, model: Union[None, str, Type[Model], Model] = None, view_type: str = "List") -> Titles:
263
+ @classmethod
264
+ def get_view_titles(cls, model: Union[None, str, Type[Model], Model] = None, view_type: str = "List") -> Titles:
264
265
  """
265
266
  Resolve and return the `Titles` component instance.
266
267
 
267
268
  Resolution order:
268
- 1) If `self.view_titles` is set on the current view, use it.
269
- 2) Else, if `model` is provided, copy the `view_titles` from the view
270
- class associated with that model via `lookup.get_view_for_model(model, action)`.
269
+ 1) If `.view_titles` is set on the current view, use it.
270
+ 2) Else, if `model` is provided, copy the `view_titles` from the view class associated with that model
271
+ via `lookup.get_view_for_model(model, action)`.
271
272
  3) Else, instantiate and return the default `Titles()`.
272
273
 
273
274
  Args:
274
275
  model: A Django model **class**, **instance**, dotted name string, or `None`.
275
276
  Passed to `lookup.get_view_for_model()` to find the related view class.
276
277
  If `None`, only local/default resolution is used.
277
- view_type: Logical view type used by `lookup.get_view_for_model()` (e.g., `"List"` or empty to construct `"DeviceView"` string).
278
+ view_type: Logical view type used by `lookup.get_view_for_model()`
279
+ (e.g., `"List"` or empty to construct `"DeviceView"` string).
278
280
 
279
281
  Returns:
280
282
  Titles: A concrete `Titles` component instance ready to use.
281
283
  """
282
- return self._resolve_component("view_titles", Titles, model, view_type)
284
+ return cls._resolve_component("view_titles", Titles, model, view_type)
283
285
 
286
+ @classmethod
284
287
  def get_breadcrumbs(
285
- self, model: Union[None, str, Type[Model], Model] = None, view_type: str = "List"
288
+ cls, model: Union[None, str, Type[Model], Model] = None, view_type: str = "List"
286
289
  ) -> Breadcrumbs:
287
290
  """
288
291
  Resolve and return the `Breadcrumbs` component instance.
289
292
 
290
293
  Resolution order mirrors `get_view_titles()`:
291
- 1) Use `self.breadcrumbs` if set locally.
292
- 2) Else, if `model` is provided, copy the `breadcrumbs` from the view
293
- class associated with that model via `lookup.get_view_for_model(model, action)`.
294
+ 1) Use `.breadcrumbs` if set locally.
295
+ 2) Else, if `model` is provided, copy the `breadcrumbs` from the view class associated with that model
296
+ via `lookup.get_view_for_model(model, action)`.
294
297
  3) Else return a new default `Breadcrumbs()`.
295
298
 
296
299
  Args:
297
300
  model: A Django model **class**, **instance**, dotted name string, or `None`.
298
301
  Passed to `lookup.get_view_for_model()` to find the related view class.
299
302
  If `None`, only local/default resolution is used.
300
- view_type: Logical view type used by `lookup.get_view_for_model()` (e.g., `"List"` or empty to construct `"DeviceView"` string).
303
+ view_type: Logical view type used by `lookup.get_view_for_model()`
304
+ (e.g., `"List"` or empty to construct `"DeviceView"` string).
301
305
 
302
306
  Returns:
303
307
  Breadcrumbs: A concrete `Breadcrumbs` component instance.
304
308
  """
305
- return self._resolve_component("breadcrumbs", Breadcrumbs, model, view_type)
309
+ return cls._resolve_component("breadcrumbs", Breadcrumbs, model, view_type)
306
310
 
311
+ @classmethod
307
312
  def _resolve_component(
308
- self,
313
+ cls,
309
314
  attr_name: str,
310
315
  default_cls: Type[Union[Breadcrumbs, Titles]],
311
316
  model: Union[None, str, Type[Model], Model] = None,
@@ -326,14 +331,14 @@ class UIComponentsMixin:
326
331
  Returns:
327
332
  Breadcrumbs/Title instance.
328
333
  """
329
- local = getattr(self, attr_name, None)
334
+ local = getattr(cls, attr_name, None)
330
335
  if local is not None:
331
- return self._instantiate_if_needed(local, default_cls)
336
+ return cls._instantiate_if_needed(local, default_cls)
332
337
 
333
338
  if model is not None:
334
339
  view_class = lookup.get_view_for_model(model, view_type)
335
340
  view_component = getattr(view_class, attr_name, None)
336
- return self._instantiate_if_needed(view_component, default_cls)
341
+ return cls._instantiate_if_needed(view_component, default_cls)
337
342
 
338
343
  return default_cls()
339
344
 
@@ -31,6 +31,7 @@ from nautobot.dcim.choices import (
31
31
  ControllerCapabilitiesChoices,
32
32
  DeviceFaceChoices,
33
33
  DeviceRedundancyGroupFailoverStrategyChoices,
34
+ InterfaceDuplexChoices,
34
35
  InterfaceModeChoices,
35
36
  InterfaceRedundancyGroupProtocolChoices,
36
37
  InterfaceTypeChoices,
@@ -704,6 +705,8 @@ class InterfaceSerializer(
704
705
  mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
705
706
  mac_address = serializers.CharField(allow_blank=True, allow_null=True, required=False)
706
707
  ip_address_count = serializers.IntegerField(read_only=True, source="_ip_address_count")
708
+ speed = serializers.IntegerField(required=False, allow_null=True)
709
+ duplex = ChoiceField(choices=InterfaceDuplexChoices, allow_blank=True, required=False)
707
710
 
708
711
  class Meta:
709
712
  model = Interface
nautobot/dcim/choices.py CHANGED
@@ -1138,6 +1138,55 @@ class InterfaceModeChoices(ChoiceSet):
1138
1138
  )
1139
1139
 
1140
1140
 
1141
+ class InterfaceDuplexChoices(ChoiceSet):
1142
+ DUPLEX_AUTO = "auto"
1143
+ DUPLEX_FULL = "full"
1144
+ DUPLEX_HALF = "half"
1145
+
1146
+ CHOICES = (
1147
+ (DUPLEX_AUTO, "Auto"),
1148
+ (DUPLEX_FULL, "Full"),
1149
+ (DUPLEX_HALF, "Half"),
1150
+ )
1151
+
1152
+
1153
+ class InterfaceSpeedChoices(ChoiceSet):
1154
+ # Stored in Kbps (for compatibility with circuits and humanize_speed filter)
1155
+ SPEED_1M = 1_000
1156
+ SPEED_10M = 10_000
1157
+ SPEED_100M = 100_000
1158
+ SPEED_1G = 1_000_000
1159
+ SPEED_2_5G = 2_500_000
1160
+ SPEED_5G = 5_000_000
1161
+ SPEED_10G = 10_000_000
1162
+ SPEED_25G = 25_000_000
1163
+ SPEED_40G = 40_000_000
1164
+ SPEED_50G = 50_000_000
1165
+ SPEED_100G = 100_000_000
1166
+ SPEED_200G = 200_000_000
1167
+ SPEED_400G = 400_000_000
1168
+ SPEED_800G = 800_000_000
1169
+ SPEED_1_6T = 1_600_000_000
1170
+
1171
+ CHOICES = (
1172
+ (SPEED_1M, "1 Mbps"),
1173
+ (SPEED_10M, "10 Mbps"),
1174
+ (SPEED_100M, "100 Mbps"),
1175
+ (SPEED_1G, "1 Gbps"),
1176
+ (SPEED_2_5G, "2.5 Gbps"),
1177
+ (SPEED_5G, "5 Gbps"),
1178
+ (SPEED_10G, "10 Gbps"),
1179
+ (SPEED_25G, "25 Gbps"),
1180
+ (SPEED_40G, "40 Gbps"),
1181
+ (SPEED_50G, "50 Gbps"),
1182
+ (SPEED_100G, "100 Gbps"),
1183
+ (SPEED_200G, "200 Gbps"),
1184
+ (SPEED_400G, "400 Gbps"),
1185
+ (SPEED_800G, "800 Gbps"),
1186
+ (SPEED_1_6T, "1.6 Tbps"),
1187
+ )
1188
+
1189
+
1141
1190
  class InterfaceStatusChoices(ChoiceSet):
1142
1191
  STATUS_PLANNED = "planned"
1143
1192
  STATUS_ACTIVE = "active"
@@ -37,6 +37,13 @@ VIRTUAL_IFACE_TYPES = interface_type_by_category["Virtual interfaces"]
37
37
 
38
38
  NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
39
39
 
40
+ COPPER_TWISTED_PAIR_IFACE_TYPES = [
41
+ InterfaceTypeChoices.TYPE_100ME_FIXED,
42
+ InterfaceTypeChoices.TYPE_1GE_FIXED,
43
+ InterfaceTypeChoices.TYPE_2GE_FIXED,
44
+ InterfaceTypeChoices.TYPE_5GE_FIXED,
45
+ InterfaceTypeChoices.TYPE_10GE_FIXED,
46
+ ]
40
47
 
41
48
  #
42
49
  # PowerFeeds
@@ -9,6 +9,7 @@ from nautobot.core.filters import (
9
9
  ContentTypeMultipleChoiceFilter,
10
10
  MultiValueCharFilter,
11
11
  MultiValueMACAddressFilter,
12
+ MultiValueNumberFilter,
12
13
  MultiValueUUIDFilter,
13
14
  NameSearchFilterSet,
14
15
  NaturalKeyOrPKMultipleChoiceFilter,
@@ -22,6 +23,8 @@ from nautobot.dcim.choices import (
22
23
  CableTypeChoices,
23
24
  ConsolePortTypeChoices,
24
25
  ControllerCapabilitiesChoices,
26
+ InterfaceDuplexChoices,
27
+ InterfaceSpeedChoices,
25
28
  InterfaceTypeChoices,
26
29
  PowerOutletTypeChoices,
27
30
  PowerPortTypeChoices,
@@ -1197,6 +1200,8 @@ class InterfaceFilterSet(
1197
1200
  vlan_id = django_filters.CharFilter(method="filter_vlan_id", label="Assigned VLAN")
1198
1201
  vlan = django_filters.NumberFilter(method="filter_vlan", label="Assigned VID")
1199
1202
  type = django_filters.MultipleChoiceFilter(choices=InterfaceTypeChoices, null_value=None)
1203
+ duplex = django_filters.MultipleChoiceFilter(choices=InterfaceDuplexChoices, null_value=None)
1204
+ speed = MultiValueNumberFilter(lookup_expr="exact", choices=InterfaceSpeedChoices)
1200
1205
  interface_redundancy_groups = NaturalKeyOrPKMultipleChoiceFilter(
1201
1206
  queryset=InterfaceRedundancyGroup.objects.all(),
1202
1207
  to_field_name="name",
@@ -1230,6 +1235,8 @@ class InterfaceFilterSet(
1230
1235
  "id",
1231
1236
  "name",
1232
1237
  "type",
1238
+ "duplex",
1239
+ "speed",
1233
1240
  "enabled",
1234
1241
  "mtu",
1235
1242
  "mgmt_only",
nautobot/dcim/forms.py CHANGED
@@ -27,7 +27,9 @@ from nautobot.core.forms import (
27
27
  form_from_model,
28
28
  JSONArrayFormField,
29
29
  MultipleContentTypeField,
30
+ MultiValueCharInput,
30
31
  NullableDateField,
32
+ NumberWithSelect,
31
33
  NumericArrayField,
32
34
  SelectWithPK,
33
35
  SmallTextarea,
@@ -37,7 +39,8 @@ from nautobot.core.forms import (
37
39
  )
38
40
  from nautobot.core.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
39
41
  from nautobot.core.forms.fields import LaxURLField
40
- from nautobot.dcim.constants import RACK_U_HEIGHT_MAXIMUM
42
+ from nautobot.core.utils.config import get_settings_or_config
43
+ from nautobot.dcim.constants import RACK_U_HEIGHT_DEFAULT, RACK_U_HEIGHT_MAXIMUM
41
44
  from nautobot.dcim.form_mixins import (
42
45
  LocatableModelBulkEditFormMixin,
43
46
  LocatableModelFilterFormMixin,
@@ -84,8 +87,10 @@ from .choices import (
84
87
  ControllerCapabilitiesChoices,
85
88
  DeviceFaceChoices,
86
89
  DeviceRedundancyGroupFailoverStrategyChoices,
90
+ InterfaceDuplexChoices,
87
91
  InterfaceModeChoices,
88
92
  InterfaceRedundancyGroupProtocolChoices,
93
+ InterfaceSpeedChoices,
89
94
  InterfaceTypeChoices,
90
95
  LocationDataToContactActionChoices,
91
96
  PortTypeChoices,
@@ -527,6 +532,17 @@ class RackForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm):
527
532
  )
528
533
  comments = CommentField()
529
534
 
535
+ def __init__(self, *args, **kwargs):
536
+ super().__init__(*args, **kwargs)
537
+ # Set initial value for u_height from Constance config when creating a new rack
538
+ if not self.instance.present_in_database and not kwargs.get("data"):
539
+ # Only set initial if this is a new form (not submitted data)
540
+ config_default = get_settings_or_config("RACK_DEFAULT_U_HEIGHT", fallback=RACK_U_HEIGHT_DEFAULT)
541
+ self.fields["u_height"].initial = config_default
542
+ # Override the form's initial dict to ensure it displays the Constance config value
543
+ # (unconditionally set it, even if already present from model default)
544
+ self.initial["u_height"] = config_default
545
+
530
546
  class Meta:
531
547
  model = Rack
532
548
  fields = [
@@ -1460,16 +1476,29 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
1460
1476
  "label",
1461
1477
  "type",
1462
1478
  "mgmt_only",
1479
+ "speed",
1480
+ "duplex",
1463
1481
  "description",
1464
1482
  ]
1465
1483
  widgets = {
1466
1484
  "type": StaticSelect2(),
1485
+ "speed": NumberWithSelect(choices=InterfaceSpeedChoices),
1486
+ "duplex": StaticSelect2(),
1487
+ }
1488
+ labels = {
1489
+ "speed": "Speed (Kbps)",
1467
1490
  }
1468
1491
 
1469
1492
 
1470
1493
  class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm):
1471
1494
  type = forms.ChoiceField(choices=InterfaceTypeChoices, widget=StaticSelect2())
1472
1495
  mgmt_only = forms.BooleanField(required=False, label="Management only")
1496
+ speed = forms.IntegerField(
1497
+ required=False, min_value=0, label="Speed (Kbps)", widget=NumberWithSelect(choices=InterfaceSpeedChoices)
1498
+ )
1499
+ duplex = forms.ChoiceField(
1500
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
1501
+ )
1473
1502
  field_order = (
1474
1503
  "device_type",
1475
1504
  "module_family",
@@ -1478,6 +1507,8 @@ class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm):
1478
1507
  "label_pattern",
1479
1508
  "type",
1480
1509
  "mgmt_only",
1510
+ "speed",
1511
+ "duplex",
1481
1512
  "description",
1482
1513
  )
1483
1514
 
@@ -1491,10 +1522,16 @@ class InterfaceTemplateBulkEditForm(NautobotBulkEditForm):
1491
1522
  widget=StaticSelect2(),
1492
1523
  )
1493
1524
  mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label="Management only")
1525
+ speed = forms.IntegerField(
1526
+ required=False, min_value=0, label="Speed (Kbps)", widget=NumberWithSelect(choices=InterfaceSpeedChoices)
1527
+ )
1528
+ duplex = forms.ChoiceField(
1529
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
1530
+ )
1494
1531
  description = forms.CharField(required=False)
1495
1532
 
1496
1533
  class Meta:
1497
- nullable_fields = ["label", "description"]
1534
+ nullable_fields = ["label", "speed", "duplex", "description"]
1498
1535
 
1499
1536
 
1500
1537
  class FrontPortTemplateForm(ModularComponentTemplateForm):
@@ -1965,6 +2002,8 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
1965
2002
  "label",
1966
2003
  "type",
1967
2004
  "mgmt_only",
2005
+ "speed",
2006
+ "duplex",
1968
2007
  ]
1969
2008
 
1970
2009
 
@@ -3131,6 +3170,7 @@ class PowerOutletBulkEditForm(
3131
3170
  class InterfaceFilterForm(ModularDeviceComponentFilterForm, RoleModelFilterFormMixin, StatusModelFilterFormMixin):
3132
3171
  model = Interface
3133
3172
  type = forms.MultipleChoiceField(choices=InterfaceTypeChoices, required=False, widget=StaticSelect2Multiple())
3173
+ speed = forms.MultipleChoiceField(choices=InterfaceSpeedChoices, required=False, widget=MultiValueCharInput)
3134
3174
  enabled = forms.NullBooleanField(required=False, widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES))
3135
3175
  mgmt_only = forms.NullBooleanField(required=False, widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES))
3136
3176
  mac_address = forms.CharField(required=False, label="MAC address")
@@ -3215,6 +3255,8 @@ class InterfaceForm(InterfaceCommonForm, ModularComponentEditForm):
3215
3255
  "bridge",
3216
3256
  "lag",
3217
3257
  "mac_address",
3258
+ "speed",
3259
+ "duplex",
3218
3260
  "ip_addresses",
3219
3261
  "virtual_device_contexts",
3220
3262
  "mtu",
@@ -3230,9 +3272,12 @@ class InterfaceForm(InterfaceCommonForm, ModularComponentEditForm):
3230
3272
  widgets = {
3231
3273
  "type": StaticSelect2(),
3232
3274
  "mode": StaticSelect2(),
3275
+ "speed": NumberWithSelect(choices=InterfaceSpeedChoices),
3276
+ "duplex": StaticSelect2(),
3233
3277
  }
3234
3278
  labels = {
3235
3279
  "mode": "802.1Q Mode",
3280
+ "speed": "Speed (Kbps)",
3236
3281
  }
3237
3282
  help_texts = {
3238
3283
  "mode": INTERFACE_MODE_HELP_TEXT,
@@ -3305,6 +3350,12 @@ class InterfaceCreateForm(ModularComponentCreateForm, InterfaceCommonForm, RoleN
3305
3350
  },
3306
3351
  )
3307
3352
  mac_address = forms.CharField(required=False, label="MAC Address")
3353
+ speed = forms.IntegerField(
3354
+ required=False, min_value=0, label="Speed (Kbps)", widget=NumberWithSelect(choices=InterfaceSpeedChoices)
3355
+ )
3356
+ duplex = forms.ChoiceField(
3357
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
3358
+ )
3308
3359
  mgmt_only = forms.BooleanField(
3309
3360
  required=False,
3310
3361
  label="Management only",
@@ -3351,6 +3402,8 @@ class InterfaceCreateForm(ModularComponentCreateForm, InterfaceCommonForm, RoleN
3351
3402
  "status",
3352
3403
  "role",
3353
3404
  "type",
3405
+ "speed",
3406
+ "duplex",
3354
3407
  "enabled",
3355
3408
  "parent_interface",
3356
3409
  "bridge",
@@ -3384,6 +3437,10 @@ class InterfaceBulkCreateForm(
3384
3437
  queryset=Status.objects.all(),
3385
3438
  query_params={"content_types": Interface._meta.label_lower},
3386
3439
  )
3440
+ speed = forms.IntegerField(required=False, min_value=0, label="Speed (Kbps)")
3441
+ duplex = forms.ChoiceField(
3442
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
3443
+ )
3387
3444
 
3388
3445
  field_order = (
3389
3446
  "name_pattern",
@@ -3397,6 +3454,8 @@ class InterfaceBulkCreateForm(
3397
3454
  "mgmt_only",
3398
3455
  "description",
3399
3456
  "mode",
3457
+ "speed",
3458
+ "duplex",
3400
3459
  "tags",
3401
3460
  )
3402
3461
 
@@ -3416,6 +3475,10 @@ class ModuleInterfaceBulkCreateForm(
3416
3475
  queryset=Status.objects.all(),
3417
3476
  query_params={"content_types": Interface._meta.label_lower},
3418
3477
  )
3478
+ speed = forms.IntegerField(required=False, min_value=0, label="Speed (Kbps)")
3479
+ duplex = forms.ChoiceField(
3480
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
3481
+ )
3419
3482
 
3420
3483
  field_order = (
3421
3484
  "name_pattern",
@@ -3429,13 +3492,28 @@ class ModuleInterfaceBulkCreateForm(
3429
3492
  "mgmt_only",
3430
3493
  "description",
3431
3494
  "mode",
3495
+ "speed",
3496
+ "duplex",
3432
3497
  "tags",
3433
3498
  )
3434
3499
 
3435
3500
 
3436
3501
  class InterfaceBulkEditForm(
3437
3502
  form_from_model(
3438
- Interface, ["label", "type", "parent_interface", "bridge", "lag", "mac_address", "mtu", "description", "mode"]
3503
+ Interface,
3504
+ [
3505
+ "label",
3506
+ "type",
3507
+ "parent_interface",
3508
+ "bridge",
3509
+ "lag",
3510
+ "mac_address",
3511
+ "mtu",
3512
+ "description",
3513
+ "mode",
3514
+ "speed",
3515
+ "duplex",
3516
+ ],
3439
3517
  ),
3440
3518
  TagsBulkEditFormMixin,
3441
3519
  StatusModelBulkEditFormMixin,
@@ -3479,6 +3557,12 @@ class InterfaceBulkEditForm(
3479
3557
  label="VRF",
3480
3558
  required=False,
3481
3559
  )
3560
+ speed = forms.IntegerField(
3561
+ required=False, min_value=0, label="Speed (Kbps)", widget=NumberWithSelect(choices=InterfaceSpeedChoices)
3562
+ )
3563
+ duplex = forms.ChoiceField(
3564
+ choices=add_blank_choice(InterfaceDuplexChoices), required=False, widget=StaticSelect2(), label="Duplex"
3565
+ )
3482
3566
 
3483
3567
  class Meta:
3484
3568
  nullable_fields = [
@@ -3490,6 +3574,8 @@ class InterfaceBulkEditForm(
3490
3574
  "mtu",
3491
3575
  "description",
3492
3576
  "mode",
3577
+ "speed",
3578
+ "duplex",
3493
3579
  "untagged_vlan",
3494
3580
  "tagged_vlans",
3495
3581
  "vrf",
@@ -0,0 +1,32 @@
1
+ # Generated by Django 4.2.25 on 2025-11-01 22:09
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("dcim", "0074_alter_rack_u_height"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="interface",
14
+ name="duplex",
15
+ field=models.CharField(blank=True, default="", max_length=10),
16
+ ),
17
+ migrations.AddField(
18
+ model_name="interface",
19
+ name="speed",
20
+ field=models.PositiveIntegerField(blank=True, null=True),
21
+ ),
22
+ migrations.AddField(
23
+ model_name="interfacetemplate",
24
+ name="duplex",
25
+ field=models.CharField(blank=True, default="", max_length=10),
26
+ ),
27
+ migrations.AddField(
28
+ model_name="interfacetemplate",
29
+ name="speed",
30
+ field=models.PositiveIntegerField(blank=True, null=True),
31
+ ),
32
+ ]
@@ -11,6 +11,7 @@ from nautobot.core.models.fields import ForeignKeyWithAutoRelatedName, NaturalOr
11
11
  from nautobot.core.models.ordering import naturalize_interface
12
12
  from nautobot.dcim.choices import (
13
13
  ConsolePortTypeChoices,
14
+ InterfaceDuplexChoices,
14
15
  InterfaceTypeChoices,
15
16
  PortTypeChoices,
16
17
  PowerOutletFeedLegChoices,
@@ -18,7 +19,13 @@ from nautobot.dcim.choices import (
18
19
  PowerPortTypeChoices,
19
20
  SubdeviceRoleChoices,
20
21
  )
21
- from nautobot.dcim.constants import REARPORT_POSITIONS_MAX, REARPORT_POSITIONS_MIN
22
+ from nautobot.dcim.constants import (
23
+ COPPER_TWISTED_PAIR_IFACE_TYPES,
24
+ REARPORT_POSITIONS_MAX,
25
+ REARPORT_POSITIONS_MIN,
26
+ VIRTUAL_IFACE_TYPES,
27
+ WIRELESS_IFACE_TYPES,
28
+ )
22
29
  from nautobot.extras.models import (
23
30
  ChangeLoggedModel,
24
31
  ContactMixin,
@@ -349,6 +356,29 @@ class InterfaceTemplate(ModularComponentTemplateModel):
349
356
  )
350
357
  type = models.CharField(max_length=50, choices=InterfaceTypeChoices)
351
358
  mgmt_only = models.BooleanField(default=False, verbose_name="Management only")
359
+ speed = models.PositiveIntegerField(null=True, blank=True)
360
+ duplex = models.CharField(max_length=10, choices=InterfaceDuplexChoices, blank=True, default="")
361
+
362
+ def clean(self):
363
+ super().clean()
364
+ self._validate_speed_and_duplex()
365
+
366
+ def _validate_speed_and_duplex(self):
367
+ """Validate speed (Kbps) and duplex based on interface type."""
368
+
369
+ is_lag = self.type == InterfaceTypeChoices.TYPE_LAG
370
+ is_virtual = self.type in VIRTUAL_IFACE_TYPES
371
+ is_wireless = self.type in WIRELESS_IFACE_TYPES
372
+
373
+ # Check settings by interface type
374
+ if self.speed and any([is_lag, is_virtual, is_wireless]):
375
+ raise ValidationError({"speed": "Speed is not applicable to this interface type."})
376
+
377
+ if self.duplex and any([is_lag, is_virtual, is_wireless]):
378
+ raise ValidationError({"duplex": "Duplex is not applicable to this interface type."})
379
+
380
+ if self.duplex and self.type not in COPPER_TWISTED_PAIR_IFACE_TYPES:
381
+ raise ValidationError({"duplex": "Duplex is only applicable to copper twisted-pair interfaces."})
352
382
 
353
383
  def instantiate(self, device, module=None):
354
384
  try:
@@ -361,6 +391,8 @@ class InterfaceTemplate(ModularComponentTemplateModel):
361
391
  module=module,
362
392
  type=self.type,
363
393
  mgmt_only=self.mgmt_only,
394
+ speed=self.speed,
395
+ duplex=self.duplex,
364
396
  status=status,
365
397
  )
366
398
 
@@ -19,6 +19,7 @@ from nautobot.core.models.tree_queries import TreeModel
19
19
  from nautobot.core.utils.data import UtilizationData
20
20
  from nautobot.dcim.choices import (
21
21
  ConsolePortTypeChoices,
22
+ InterfaceDuplexChoices,
22
23
  InterfaceModeChoices,
23
24
  InterfaceRedundancyGroupProtocolChoices,
24
25
  InterfaceStatusChoices,
@@ -31,6 +32,7 @@ from nautobot.dcim.choices import (
31
32
  SubdeviceRoleChoices,
32
33
  )
33
34
  from nautobot.dcim.constants import (
35
+ COPPER_TWISTED_PAIR_IFACE_TYPES,
34
36
  NONCONNECTABLE_IFACE_TYPES,
35
37
  REARPORT_POSITIONS_MAX,
36
38
  REARPORT_POSITIONS_MIN,
@@ -743,6 +745,9 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
743
745
  blank=True,
744
746
  verbose_name="IP Addresses",
745
747
  )
748
+ # Operational attributes (distinct from interface type capabilities)
749
+ speed = models.PositiveIntegerField(null=True, blank=True)
750
+ duplex = models.CharField(max_length=10, choices=InterfaceDuplexChoices, blank=True, default="")
746
751
 
747
752
  class Meta(ModularComponentModel.Meta):
748
753
  ordering = ("device", "module__id", CollateAsChar("_name")) # Module.ordering is complex; don't order by module
@@ -877,6 +882,22 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
877
882
  }
878
883
  )
879
884
 
885
+ # Speed/Duplex validation
886
+ self._validate_speed_and_duplex()
887
+
888
+ def _validate_speed_and_duplex(self):
889
+ """Validate speed (Kbps) and duplex based on interface type."""
890
+
891
+ # Check settings by interface type
892
+ if self.speed and any([self.is_lag, self.is_virtual, self.is_wireless]):
893
+ raise ValidationError({"speed": "Speed is not applicable to this interface type."})
894
+
895
+ if self.duplex and any([self.is_lag, self.is_virtual, self.is_wireless]):
896
+ raise ValidationError({"duplex": "Duplex is not applicable to this interface type."})
897
+
898
+ if self.duplex and self.type not in COPPER_TWISTED_PAIR_IFACE_TYPES:
899
+ raise ValidationError({"duplex": "Duplex is only applicable to copper twisted-pair interfaces."})
900
+
880
901
  @property
881
902
  def is_connectable(self):
882
903
  return self.type not in NONCONNECTABLE_IFACE_TYPES