nautobot 2.4.21__py3-none-any.whl → 2.4.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nautobot/apps/choices.py +4 -0
- nautobot/apps/utils.py +8 -0
- nautobot/circuits/views.py +6 -2
- nautobot/core/cli/migrate_deprecated_templates.py +28 -9
- nautobot/core/filters.py +4 -0
- nautobot/core/forms/__init__.py +2 -0
- nautobot/core/forms/widgets.py +21 -2
- nautobot/core/settings.py +6 -0
- nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
- nautobot/core/templatetags/helpers.py +9 -7
- nautobot/core/tests/nautobot_config.py +3 -0
- nautobot/core/tests/test_templatetags_helpers.py +6 -0
- nautobot/core/tests/test_ui.py +49 -1
- nautobot/core/tests/test_utils.py +41 -1
- nautobot/core/ui/object_detail.py +7 -2
- nautobot/core/urls.py +7 -8
- nautobot/core/utils/filtering.py +11 -1
- nautobot/core/utils/lookup.py +46 -0
- nautobot/core/views/mixins.py +21 -16
- nautobot/dcim/api/serializers.py +3 -0
- nautobot/dcim/choices.py +49 -0
- nautobot/dcim/constants.py +7 -0
- nautobot/dcim/filters/__init__.py +7 -0
- nautobot/dcim/forms.py +89 -3
- nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
- nautobot/dcim/models/device_component_templates.py +33 -1
- nautobot/dcim/models/device_components.py +21 -0
- nautobot/dcim/tables/devices.py +14 -0
- nautobot/dcim/tables/devicetypes.py +8 -1
- nautobot/dcim/templates/dcim/interface.html +8 -0
- nautobot/dcim/templates/dcim/interface_edit.html +2 -0
- nautobot/dcim/tests/test_api.py +186 -6
- nautobot/dcim/tests/test_filters.py +32 -0
- nautobot/dcim/tests/test_forms.py +110 -8
- nautobot/dcim/tests/test_graphql.py +44 -1
- nautobot/dcim/tests/test_models.py +265 -0
- nautobot/dcim/tests/test_tables.py +160 -0
- nautobot/dcim/tests/test_views.py +64 -1
- nautobot/dcim/views.py +86 -77
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/templates/extras/plugin_detail.html +2 -2
- nautobot/extras/urls.py +0 -14
- nautobot/extras/views.py +1 -1
- nautobot/ipam/ui.py +0 -17
- nautobot/ipam/views.py +2 -2
- nautobot/project-static/js/forms.js +92 -14
- nautobot/virtualization/tests/test_models.py +4 -2
- nautobot/virtualization/views.py +1 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/METADATA +4 -4
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/RECORD +54 -51
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/NOTICE +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/WHEEL +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.22.dist-info}/entry_points.txt +0 -0
nautobot/core/utils/lookup.py
CHANGED
|
@@ -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.
|
nautobot/core/views/mixins.py
CHANGED
|
@@ -260,52 +260,57 @@ class UIComponentsMixin:
|
|
|
260
260
|
breadcrumbs: ClassVar[Optional[Breadcrumbs]] = None
|
|
261
261
|
view_titles: ClassVar[Optional[Titles]] = None
|
|
262
262
|
|
|
263
|
-
|
|
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
|
|
269
|
-
2) Else, if `model` is provided, copy the `view_titles` from the view
|
|
270
|
-
|
|
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()`
|
|
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
|
|
284
|
+
return cls._resolve_component("view_titles", Titles, model, view_type)
|
|
283
285
|
|
|
286
|
+
@classmethod
|
|
284
287
|
def get_breadcrumbs(
|
|
285
|
-
|
|
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
|
|
292
|
-
2) Else, if `model` is provided, copy the `breadcrumbs` from the view
|
|
293
|
-
|
|
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()`
|
|
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
|
|
309
|
+
return cls._resolve_component("breadcrumbs", Breadcrumbs, model, view_type)
|
|
306
310
|
|
|
311
|
+
@classmethod
|
|
307
312
|
def _resolve_component(
|
|
308
|
-
|
|
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(
|
|
334
|
+
local = getattr(cls, attr_name, None)
|
|
330
335
|
if local is not None:
|
|
331
|
-
return
|
|
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
|
|
341
|
+
return cls._instantiate_if_needed(view_component, default_cls)
|
|
337
342
|
|
|
338
343
|
return default_cls()
|
|
339
344
|
|
nautobot/dcim/api/serializers.py
CHANGED
|
@@ -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"
|
nautobot/dcim/constants.py
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
|
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
|