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.

Files changed (62) 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/jobs/bulk_actions.py +12 -6
  9. nautobot/core/jobs/cleanup.py +13 -1
  10. nautobot/core/settings.py +6 -0
  11. nautobot/core/settings_funcs.py +11 -1
  12. nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
  13. nautobot/core/templatetags/helpers.py +9 -7
  14. nautobot/core/tests/nautobot_config.py +3 -0
  15. nautobot/core/tests/test_jobs.py +118 -0
  16. nautobot/core/tests/test_templatetags_helpers.py +6 -0
  17. nautobot/core/tests/test_ui.py +49 -1
  18. nautobot/core/tests/test_utils.py +41 -1
  19. nautobot/core/ui/object_detail.py +7 -2
  20. nautobot/core/urls.py +7 -8
  21. nautobot/core/utils/filtering.py +11 -1
  22. nautobot/core/utils/lookup.py +46 -0
  23. nautobot/core/views/mixins.py +23 -17
  24. nautobot/core/views/utils.py +3 -3
  25. nautobot/dcim/api/serializers.py +3 -0
  26. nautobot/dcim/choices.py +49 -0
  27. nautobot/dcim/constants.py +7 -0
  28. nautobot/dcim/filters/__init__.py +7 -0
  29. nautobot/dcim/forms.py +89 -3
  30. nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
  31. nautobot/dcim/models/device_component_templates.py +33 -1
  32. nautobot/dcim/models/device_components.py +21 -0
  33. nautobot/dcim/tables/devices.py +14 -0
  34. nautobot/dcim/tables/devicetypes.py +8 -1
  35. nautobot/dcim/templates/dcim/interface.html +8 -0
  36. nautobot/dcim/templates/dcim/interface_edit.html +2 -0
  37. nautobot/dcim/tests/test_api.py +186 -6
  38. nautobot/dcim/tests/test_filters.py +32 -0
  39. nautobot/dcim/tests/test_forms.py +110 -8
  40. nautobot/dcim/tests/test_graphql.py +44 -1
  41. nautobot/dcim/tests/test_models.py +265 -0
  42. nautobot/dcim/tests/test_tables.py +160 -0
  43. nautobot/dcim/tests/test_views.py +64 -1
  44. nautobot/dcim/views.py +86 -77
  45. nautobot/extras/forms/forms.py +3 -1
  46. nautobot/extras/jobs.py +48 -2
  47. nautobot/extras/models/models.py +19 -0
  48. nautobot/extras/models/relationships.py +3 -1
  49. nautobot/extras/templates/extras/plugin_detail.html +2 -2
  50. nautobot/extras/urls.py +0 -14
  51. nautobot/extras/views.py +1 -1
  52. nautobot/ipam/ui.py +0 -17
  53. nautobot/ipam/views.py +2 -2
  54. nautobot/project-static/js/forms.js +92 -14
  55. nautobot/virtualization/tests/test_models.py +4 -2
  56. nautobot/virtualization/views.py +1 -0
  57. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/METADATA +4 -4
  58. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/RECORD +62 -59
  59. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/LICENSE.txt +0 -0
  60. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/NOTICE +0 -0
  61. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/WHEEL +0 -0
  62. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/entry_points.txt +0 -0
@@ -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 filters as dcim_filters, forms as dcim_forms, models as dcim_models, tables
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
- return_url += f"?tab={self.tab_id}"
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
- try:
95
- import debug_toolbar
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:
@@ -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
- form_field = forms.IntegerField()
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"
@@ -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
 
@@ -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
- self.success_url = request.get_full_path()
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()):
@@ -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, as filterset_class the view as a param, will not work
590
- # with a job. It is better to be consistent with each with sending the same params that will
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:
@@ -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",