nautobot 2.4.21__py3-none-any.whl → 2.4.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- 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/jobs/bulk_actions.py +12 -6
- nautobot/core/jobs/cleanup.py +13 -1
- nautobot/core/settings.py +6 -0
- nautobot/core/settings_funcs.py +11 -1
- 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_jobs.py +118 -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 +23 -17
- nautobot/core/views/utils.py +3 -3
- 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/jobs.py +48 -2
- nautobot/extras/models/models.py +19 -0
- nautobot/extras/models/relationships.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.23.dist-info}/METADATA +4 -4
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/RECORD +62 -59
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/NOTICE +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/WHEEL +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/entry_points.txt +0 -0
nautobot/core/tests/test_ui.py
CHANGED
|
@@ -23,8 +23,10 @@ from nautobot.core.ui.object_detail import (
|
|
|
23
23
|
Panel,
|
|
24
24
|
SectionChoices,
|
|
25
25
|
)
|
|
26
|
-
from nautobot.dcim.models import DeviceRedundancyGroup
|
|
26
|
+
from nautobot.dcim.models import Device, DeviceRedundancyGroup
|
|
27
|
+
from nautobot.dcim.tables import DeviceModuleInterfaceTable
|
|
27
28
|
from nautobot.dcim.tables.devices import DeviceTable
|
|
29
|
+
from nautobot.dcim.views import DeviceUIViewSet
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class DataTablePanelTest(TestCase):
|
|
@@ -288,6 +290,52 @@ class ObjectDetailContentExtraTabsTest(TestCase):
|
|
|
288
290
|
self.default_tabs_id.append("services")
|
|
289
291
|
self.assertListEqual(tab_ids, self.default_tabs_id)
|
|
290
292
|
|
|
293
|
+
def test_tab_id_url_as_action(self):
|
|
294
|
+
"""
|
|
295
|
+
Test that when you create a panel with a tab_id that matches a viewset action,
|
|
296
|
+
the return_url is constructed correctly.
|
|
297
|
+
"""
|
|
298
|
+
self.add_permissions("dcim.add_interface", "dcim.change_interface")
|
|
299
|
+
device_info = Device.objects.first()
|
|
300
|
+
|
|
301
|
+
panel = DeviceUIViewSet.DeviceInterfacesTablePanel(
|
|
302
|
+
weight=100,
|
|
303
|
+
section=SectionChoices.FULL_WIDTH,
|
|
304
|
+
table_title="Interfaces",
|
|
305
|
+
table_class=DeviceModuleInterfaceTable,
|
|
306
|
+
table_attribute="vc_interfaces",
|
|
307
|
+
related_field_name="device",
|
|
308
|
+
tab_id="interfaces",
|
|
309
|
+
)
|
|
310
|
+
context = {"request": self.request, "object": device_info}
|
|
311
|
+
panel_context = panel.get_extra_context(context)
|
|
312
|
+
|
|
313
|
+
return_url = f"/dcim/devices/{device_info.pk}/interfaces/"
|
|
314
|
+
self.assertTrue(panel_context["body_content_table_add_url"].endswith(return_url))
|
|
315
|
+
|
|
316
|
+
def test_tab_id_url_as_param(self):
|
|
317
|
+
"""
|
|
318
|
+
Test that when you create a panel with a tab_id that does NOT matches a viewset action,
|
|
319
|
+
the return_url is constructed correctly.
|
|
320
|
+
"""
|
|
321
|
+
self.add_permissions("dcim.add_interface", "dcim.change_interface")
|
|
322
|
+
device_info = Device.objects.first()
|
|
323
|
+
|
|
324
|
+
panel = DeviceUIViewSet.DeviceInterfacesTablePanel(
|
|
325
|
+
weight=100,
|
|
326
|
+
section=SectionChoices.FULL_WIDTH,
|
|
327
|
+
table_title="Interfaces",
|
|
328
|
+
table_class=DeviceModuleInterfaceTable,
|
|
329
|
+
table_attribute="vc_interfaces",
|
|
330
|
+
related_field_name="device",
|
|
331
|
+
tab_id="interfaces-not-exist",
|
|
332
|
+
)
|
|
333
|
+
context = {"request": self.request, "object": device_info}
|
|
334
|
+
panel_context = panel.get_extra_context(context)
|
|
335
|
+
|
|
336
|
+
return_url = f"&return_url=/dcim/devices/{device_info.pk}/?tab=interfaces-not-exist"
|
|
337
|
+
self.assertTrue(panel_context["body_content_table_add_url"].endswith(return_url))
|
|
338
|
+
|
|
291
339
|
def test_extra_tab_panel_context(self):
|
|
292
340
|
"""
|
|
293
341
|
Confirming that extra tab panels produce the correct context,
|
|
@@ -18,7 +18,13 @@ from nautobot.core.testing import TestCase
|
|
|
18
18
|
from nautobot.core.utils import data as data_utils, filtering, lookup, querysets, requests
|
|
19
19
|
from nautobot.core.utils.migrations import update_object_change_ct_for_replaced_models
|
|
20
20
|
from nautobot.core.utils.module_loading import check_name_safe_to_import_privately
|
|
21
|
-
from nautobot.dcim import
|
|
21
|
+
from nautobot.dcim import (
|
|
22
|
+
filters as dcim_filters,
|
|
23
|
+
forms as dcim_forms,
|
|
24
|
+
models as dcim_models,
|
|
25
|
+
tables,
|
|
26
|
+
views as dcim_views,
|
|
27
|
+
)
|
|
22
28
|
from nautobot.extras import models as extras_models, utils as extras_utils
|
|
23
29
|
from nautobot.extras.choices import ObjectChangeActionChoices, RelationshipTypeChoices
|
|
24
30
|
from nautobot.extras.filters import StatusFilterSet
|
|
@@ -213,6 +219,26 @@ class FlattenIterableTest(TestCase):
|
|
|
213
219
|
class GetFooForModelTest(TestCase):
|
|
214
220
|
"""Tests for the various `get_foo_for_model()` functions."""
|
|
215
221
|
|
|
222
|
+
def test_get_breadcrumbs_for_model(self):
|
|
223
|
+
breadcrumbs = lookup.get_breadcrumbs_for_model(dcim_models.Device)
|
|
224
|
+
self.assertEqual(breadcrumbs.items, dcim_views.DeviceUIViewSet.get_breadcrumbs(dcim_models.Device).items)
|
|
225
|
+
breadcrumbs = lookup.get_breadcrumbs_for_model(dcim_models.Device, view_type="")
|
|
226
|
+
self.assertEqual(
|
|
227
|
+
breadcrumbs.items, dcim_views.DeviceUIViewSet.get_breadcrumbs(dcim_models.Device, view_type="").items
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def test_get_detail_view_components_context_for_model(self):
|
|
231
|
+
context = lookup.get_detail_view_components_context_for_model(dcim_models.Device)
|
|
232
|
+
self.assertEqual(
|
|
233
|
+
context["breadcrumbs"].items, lookup.get_breadcrumbs_for_model(dcim_models.Device, view_type="").items
|
|
234
|
+
)
|
|
235
|
+
self.assertEqual(
|
|
236
|
+
context["object_detail_content"], lookup.get_object_detail_content_for_model(dcim_models.Device)
|
|
237
|
+
)
|
|
238
|
+
self.assertEqual(
|
|
239
|
+
context["view_titles"].titles, lookup.get_view_titles_for_model(dcim_models.Device, view_type="").titles
|
|
240
|
+
)
|
|
241
|
+
|
|
216
242
|
def test_get_filterset_for_model(self):
|
|
217
243
|
"""
|
|
218
244
|
Test that `get_filterset_for_model` returns the right FilterSet for various inputs.
|
|
@@ -235,6 +261,12 @@ class GetFooForModelTest(TestCase):
|
|
|
235
261
|
self.assertEqual(lookup.get_form_for_model("dcim.location"), dcim_forms.LocationForm)
|
|
236
262
|
self.assertEqual(lookup.get_form_for_model(dcim_models.Location), dcim_forms.LocationForm)
|
|
237
263
|
|
|
264
|
+
def test_get_object_detail_content_for_model(self):
|
|
265
|
+
self.assertEqual(
|
|
266
|
+
lookup.get_object_detail_content_for_model(dcim_models.Device),
|
|
267
|
+
dcim_views.DeviceUIViewSet.object_detail_content,
|
|
268
|
+
)
|
|
269
|
+
|
|
238
270
|
def test_get_related_field_for_models(self):
|
|
239
271
|
"""
|
|
240
272
|
Test that `get_related_field_for_models` returns the appropriate field for various inputs.
|
|
@@ -341,6 +373,14 @@ class GetFooForModelTest(TestCase):
|
|
|
341
373
|
# Testing unconventional table name
|
|
342
374
|
self.assertEqual(lookup.get_table_class_string_from_view_name("ipam:prefix_list"), "PrefixDetailTable")
|
|
343
375
|
|
|
376
|
+
def test_get_view_titles_for_model(self):
|
|
377
|
+
view_titles = lookup.get_view_titles_for_model(dcim_models.Device)
|
|
378
|
+
self.assertEqual(view_titles.titles, dcim_views.DeviceUIViewSet.get_view_titles(dcim_models.Device).titles)
|
|
379
|
+
view_titles = lookup.get_view_titles_for_model(dcim_models.Device, view_type="")
|
|
380
|
+
self.assertEqual(
|
|
381
|
+
view_titles.titles, dcim_views.DeviceUIViewSet.get_view_titles(dcim_models.Device, view_type="").titles
|
|
382
|
+
)
|
|
383
|
+
|
|
344
384
|
|
|
345
385
|
class IsTaggableTest(TestCase):
|
|
346
386
|
def test_is_taggable_true(self):
|
|
@@ -39,7 +39,7 @@ from nautobot.core.templatetags.helpers import (
|
|
|
39
39
|
)
|
|
40
40
|
from nautobot.core.ui.choices import LayoutChoices, SectionChoices
|
|
41
41
|
from nautobot.core.ui.utils import render_component_template
|
|
42
|
-
from nautobot.core.utils.lookup import get_filterset_for_model, get_route_for_model
|
|
42
|
+
from nautobot.core.utils.lookup import get_filterset_for_model, get_route_for_model, get_view_for_model
|
|
43
43
|
from nautobot.core.utils.permissions import get_permission_for_model
|
|
44
44
|
from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
|
|
45
45
|
from nautobot.core.views.utils import get_obj_from_context
|
|
@@ -879,7 +879,12 @@ class ObjectsTablePanel(Panel):
|
|
|
879
879
|
related_field_name = self.related_field_name or self.table_filter or obj._meta.model_name
|
|
880
880
|
return_url = context.get("return_url", obj.get_absolute_url())
|
|
881
881
|
if self.tab_id:
|
|
882
|
-
|
|
882
|
+
try:
|
|
883
|
+
# Check to see if the this is a NautobotUIViewset action
|
|
884
|
+
view = get_view_for_model(obj._meta.model)
|
|
885
|
+
return_url += getattr(view, self.tab_id).url_path + "/"
|
|
886
|
+
except AttributeError:
|
|
887
|
+
return_url += f"?tab={self.tab_id}"
|
|
883
888
|
|
|
884
889
|
if self.add_button_route is not None:
|
|
885
890
|
add_permissions = self.add_permissions
|
nautobot/core/urls.py
CHANGED
|
@@ -91,15 +91,14 @@ urlpatterns = [
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
if settings.DEBUG:
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
urlpatterns += [path("theme-preview/", ThemePreviewView.as_view(), name="theme_preview")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if "debug_toolbar" in settings.INSTALLED_APPS:
|
|
98
|
+
from debug_toolbar.toolbar import debug_toolbar_urls
|
|
99
|
+
|
|
100
|
+
urlpatterns += debug_toolbar_urls()
|
|
96
101
|
|
|
97
|
-
urlpatterns += [
|
|
98
|
-
path("__debug__/", include(debug_toolbar.urls)),
|
|
99
|
-
path("theme-preview/", ThemePreviewView.as_view(), name="theme_preview"),
|
|
100
|
-
]
|
|
101
|
-
except ImportError:
|
|
102
|
-
pass
|
|
103
102
|
|
|
104
103
|
if settings.METRICS_ENABLED:
|
|
105
104
|
if settings.METRICS_AUTHENTICATED:
|
nautobot/core/utils/filtering.py
CHANGED
|
@@ -101,6 +101,7 @@ def get_filterset_parameter_form_field(model, parameter, filterset=None):
|
|
|
101
101
|
BOOLEAN_CHOICES,
|
|
102
102
|
DynamicModelMultipleChoiceField,
|
|
103
103
|
MultipleContentTypeField,
|
|
104
|
+
MultiValueCharInput,
|
|
104
105
|
StaticSelect2,
|
|
105
106
|
StaticSelect2Multiple,
|
|
106
107
|
)
|
|
@@ -121,7 +122,16 @@ def get_filterset_parameter_form_field(model, parameter, filterset=None):
|
|
|
121
122
|
elif isinstance(field, (MultiValueDecimalFilter, MultiValueFloatFilter)):
|
|
122
123
|
form_field = forms.DecimalField()
|
|
123
124
|
elif isinstance(field, NumberFilter):
|
|
124
|
-
|
|
125
|
+
# If "choices" are passed, then when 'exact' is used in an Advanced
|
|
126
|
+
# Filter, render a dropdown of choices instead of a free integer input
|
|
127
|
+
if field.lookup_expr == "exact" and getattr(field, "choices", None):
|
|
128
|
+
# Use a multi-value widget that allows both preset choices and free-form entries
|
|
129
|
+
form_field = forms.MultipleChoiceField(
|
|
130
|
+
choices=field.choices,
|
|
131
|
+
widget=MultiValueCharInput,
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
form_field = forms.IntegerField()
|
|
125
135
|
elif isinstance(field, ModelMultipleChoiceFilter):
|
|
126
136
|
if getattr(field, "prefers_id", False):
|
|
127
137
|
to_field_name = "id"
|
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
|
|
|
@@ -1044,7 +1049,8 @@ class ObjectEditViewMixin(NautobotViewSetMixin, mixins.CreateModelMixin, mixins.
|
|
|
1044
1049
|
if hasattr(obj, "clone_fields"):
|
|
1045
1050
|
url = f"{request.path}?{prepare_cloned_fields(obj)}"
|
|
1046
1051
|
self.success_url = url
|
|
1047
|
-
|
|
1052
|
+
else:
|
|
1053
|
+
self.success_url = request.get_full_path()
|
|
1048
1054
|
else:
|
|
1049
1055
|
return_url = form.cleaned_data.get("return_url")
|
|
1050
1056
|
if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
|
nautobot/core/views/utils.py
CHANGED
|
@@ -586,9 +586,9 @@ def get_bulk_queryset_from_view(
|
|
|
586
586
|
|
|
587
587
|
queryset = view_class.queryset.restrict(user, action)
|
|
588
588
|
|
|
589
|
-
# The filterset_class is determined from model on purpose
|
|
590
|
-
# with a job. It is better to be consistent
|
|
591
|
-
# always be available from to the confirmation page and to the job.
|
|
589
|
+
# The filterset_class is determined from model on purpose versus getting it from the view itself. This is
|
|
590
|
+
# because the filterset_class on the view as a param, will not work with a job. It is better to be consistent
|
|
591
|
+
# with each with sending the same params that will always be available from to the confirmation page and to the job.
|
|
592
592
|
filterset_class = get_filterset_for_model(model)
|
|
593
593
|
|
|
594
594
|
if not filterset_class:
|
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",
|