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
nautobot/apps/choices.py CHANGED
@@ -14,8 +14,10 @@ from nautobot.dcim.choices import (
14
14
  ConsolePortTypeChoices,
15
15
  DeviceFaceChoices,
16
16
  DeviceRedundancyGroupFailoverStrategyChoices,
17
+ InterfaceDuplexChoices,
17
18
  InterfaceModeChoices,
18
19
  InterfaceRedundancyGroupProtocolChoices,
20
+ InterfaceSpeedChoices,
19
21
  InterfaceTypeChoices,
20
22
  PortTypeChoices,
21
23
  PowerFeedBreakerPoleChoices,
@@ -84,8 +86,10 @@ __all__ = (
84
86
  "IPAddressRoleChoices",
85
87
  "IPAddressTypeChoices",
86
88
  "IPAddressVersionChoices",
89
+ "InterfaceDuplexChoices",
87
90
  "InterfaceModeChoices",
88
91
  "InterfaceRedundancyGroupProtocolChoices",
92
+ "InterfaceSpeedChoices",
89
93
  "InterfaceTypeChoices",
90
94
  "JobExecutionType",
91
95
  "JobResultStatusChoices",
nautobot/apps/utils.py CHANGED
@@ -26,10 +26,13 @@ from nautobot.core.utils.filtering import (
26
26
  from nautobot.core.utils.git import BranchDoesNotExist, convert_git_diff_log_to_list, GitRepo, swap_status_initials
27
27
  from nautobot.core.utils.logging import sanitize
28
28
  from nautobot.core.utils.lookup import (
29
+ get_breadcrumbs_for_model,
29
30
  get_changes_for_model,
31
+ get_detail_view_components_context_for_model,
30
32
  get_filterset_for_model,
31
33
  get_form_for_model,
32
34
  get_model_from_name,
35
+ get_object_detail_content_for_model,
33
36
  get_related_class_for_model,
34
37
  get_related_field_for_models,
35
38
  get_route_for_model,
@@ -37,6 +40,7 @@ from nautobot.core.utils.lookup import (
37
40
  get_url_for_url_pattern,
38
41
  get_url_patterns,
39
42
  get_view_for_model,
43
+ get_view_titles_for_model,
40
44
  )
41
45
  from nautobot.core.utils.migrations import migrate_content_type_references_to_new_model
42
46
  from nautobot.core.utils.permissions import (
@@ -97,8 +101,10 @@ __all__ = (
97
101
  "generate_signature",
98
102
  "get_all_lookup_expr_for_field",
99
103
  "get_base_template",
104
+ "get_breadcrumbs_for_model",
100
105
  "get_celery_queues",
101
106
  "get_changes_for_model",
107
+ "get_detail_view_components_context_for_model",
102
108
  "get_filter_field_label",
103
109
  "get_filterable_params_from_filter_params",
104
110
  "get_filterset_field",
@@ -107,6 +113,7 @@ __all__ = (
107
113
  "get_form_for_model",
108
114
  "get_latest_release",
109
115
  "get_model_from_name",
116
+ "get_object_detail_content_for_model",
110
117
  "get_permission_for_model",
111
118
  "get_related_class_for_model",
112
119
  "get_related_field_for_models",
@@ -116,6 +123,7 @@ __all__ = (
116
123
  "get_url_for_url_pattern",
117
124
  "get_url_patterns",
118
125
  "get_view_for_model",
126
+ "get_view_titles_for_model",
119
127
  "get_worker_count",
120
128
  "hex_to_rgb",
121
129
  "image_upload",
@@ -60,7 +60,7 @@ class CircuitTypeUIViewSet(NautobotUIViewSet):
60
60
 
61
61
  class CircuitTerminationObjectFieldsPanel(ObjectFieldsPanel):
62
62
  def get_extra_context(self, context):
63
- return {"termination": context["object"]}
63
+ return {"termination": context["object"], **super().get_extra_context(context)}
64
64
 
65
65
  def render_key(self, key, value, context):
66
66
  if key == "connected_endpoint":
@@ -226,7 +226,11 @@ class CircuitUIViewSet(NautobotUIViewSet):
226
226
  return True
227
227
 
228
228
  def get_extra_context(self, context):
229
- return {"termination": context[self.context_object_key], "side": self.side}
229
+ return {
230
+ "termination": context[self.context_object_key],
231
+ "side": self.side,
232
+ **super().get_extra_context(context),
233
+ }
230
234
 
231
235
  def get_data(self, context):
232
236
  """
@@ -4,15 +4,16 @@ import re
4
4
 
5
5
  TEMPLATE_REPLACEMENTS = {
6
6
  # Format: new_template: [old_template1, old_template2, ...]
7
- "circuits/circuit_create.html": ["circuits/circuit_edit.html"],
8
- "circuits/circuittermination_create.html": ["circuits/circuittermination_edit.html"],
9
- "circuits/provider_create.html": ["circuits/provider_edit.html"],
10
- "circuits/provider_retrieve.html": ["circuits/provider.html"],
7
+ "circuits/circuit_create.html": ["circuits/circuit_edit.html", "circuits/circuit_update.html"],
8
+ "circuits/circuittermination_create.html": [
9
+ "circuits/circuittermination_edit.html",
10
+ "circuits/circuittermination_update.html",
11
+ ],
12
+ "circuits/provider_create.html": ["circuits/provider_edit.html", "circuits/provider_update.html"],
11
13
  "dcim/cable_retrieve.html": ["dcim/cable.html"],
12
14
  "dcim/cable_update.html": ["dcim/cable_edit.html"],
13
15
  "dcim/device_create.html": ["dcim/device_edit.html"],
14
16
  "dcim/devicetype_update.html": ["dcim/devicetype_edit.html"],
15
- "dcim/location_retrieve.html": ["dcim/location.html"],
16
17
  "dcim/location_update.html": ["dcim/location_edit.html"],
17
18
  "dcim/rack_retrieve.html": ["dcim/rack.html"],
18
19
  "dcim/rack_update.html": ["dcim/rack_edit.html"],
@@ -26,10 +27,9 @@ TEMPLATE_REPLACEMENTS = {
26
27
  "extras/dynamicgroup_update.html": ["extras/dynamicgroup_edit.html"],
27
28
  "extras/gitrepository_retrieve.html": ["extras/gitrepository.html"],
28
29
  "extras/gitrepository_update.html": ["extras/gitrepository_object_edit.html"],
29
- "extras/graphqlquery_retrieve.html": ["extras/graphqlquery.html"],
30
30
  "extras/jobresult_retrieve.html": ["extras/jobresult.html"],
31
- "extras/note_retrieve.html": ["extras/note.html"],
32
31
  "extras/objectchange_retrieve.html": ["extras/objectchange.html"],
32
+ "extras/secret_create.html": ["extras/secret_edit.html"],
33
33
  "extras/secretsgroup_update.html": ["extras/secretsgroup_edit.html"],
34
34
  "extras/tag_update.html": ["extras/tag_edit.html"],
35
35
  "generic/object_bulk_create.html": ["generic/object_bulk_import.html"],
@@ -38,6 +38,7 @@ TEMPLATE_REPLACEMENTS = {
38
38
  "generic/object_changelog.html": ["extras/object_changelog.html"],
39
39
  "generic/object_create.html": ["dcim/powerpanel_edit.html", "generic/object_edit.html", "ipam/service_edit.html"],
40
40
  "generic/object_destroy.html": ["generic/object_delete.html"],
41
+ "generic/object_list.html": ["extras/graphqlquery_list.html", "extras/objectchange_list.html"],
41
42
  "generic/object_notes.html": ["extras/object_notes.html"],
42
43
  "generic/object_retrieve.html": [
43
44
  "circuits/circuit.html",
@@ -46,6 +47,8 @@ TEMPLATE_REPLACEMENTS = {
46
47
  "circuits/circuittermination_retrieve.html",
47
48
  "circuits/circuittype.html",
48
49
  "circuits/circuittype_retrieve.html",
50
+ "circuits/provider.html",
51
+ "circuits/provider_retrieve.html",
49
52
  "circuits/providernetwork.html",
50
53
  "circuits/providernetwork_retrieve.html",
51
54
  "cloud/cloudaccount_retrieve.html",
@@ -68,14 +71,18 @@ TEMPLATE_REPLACEMENTS = {
68
71
  "dcim/device/powerports.html",
69
72
  "dcim/device/rearports.html",
70
73
  "dcim/device/wireless.html",
74
+ "dcim/device_component.html",
71
75
  "dcim/devicefamily_retrieve.html",
72
76
  "dcim/deviceredundancygroup_retrieve.html",
73
77
  "dcim/devicetype.html",
74
78
  "dcim/devicetype_retrieve.html",
75
79
  "dcim/interfaceredundancygroup_retrieve.html",
80
+ "dcim/location.html",
81
+ "dcim/location_retrieve.html",
76
82
  "dcim/locationtype.html",
77
83
  "dcim/locationtype_retrieve.html",
78
84
  "dcim/manufacturer.html",
85
+ "dcim/modulebay_retrieve.html",
79
86
  "dcim/platform.html",
80
87
  "dcim/powerfeed.html",
81
88
  "dcim/powerfeed_retrieve.html",
@@ -96,11 +103,17 @@ TEMPLATE_REPLACEMENTS = {
96
103
  "extras/customfield_retrieve.html",
97
104
  "extras/customlink.html",
98
105
  "extras/exporttemplate.html",
106
+ "extras/graphqlquery.html",
107
+ "extras/graphqlquery_retrieve.html",
99
108
  "extras/job_detail.html",
100
109
  "extras/jobbutton_retrieve.html",
101
110
  "extras/jobhook.html",
102
111
  "extras/jobqueue_retrieve.html",
103
112
  "extras/metadatatype_retrieve.html",
113
+ "extras/note.html",
114
+ "extras/note_retrieve.html",
115
+ "extras/relationship.html",
116
+ "extras/secret.html",
104
117
  "extras/secretsgroup.html",
105
118
  "extras/secretsgroup_retrieve.html",
106
119
  "extras/status.html",
@@ -108,13 +121,20 @@ TEMPLATE_REPLACEMENTS = {
108
121
  "extras/tag_retrieve.html",
109
122
  "extras/team_retrieve.html",
110
123
  "generic/object_detail.html",
124
+ "ipam/namespace_retrieve.html",
125
+ "ipam/prefix.html",
126
+ "ipam/prefix_retrieve.html",
111
127
  "ipam/rir.html",
128
+ "ipam/routetarget.html",
112
129
  "ipam/service.html",
113
130
  "ipam/service_retrieve.html",
114
131
  "ipam/vlan.html",
115
132
  "ipam/vlan_retrieve.html",
116
133
  "ipam/vlangroup.html",
134
+ "ipam/vrf.html",
117
135
  "tenancy/tenant.html",
136
+ "tenancy/tenantgroup.html",
137
+ "tenancy/tenantgroup_retrieve.html",
118
138
  "virtualization/clustergroup.html",
119
139
  "virtualization/clustertype.html",
120
140
  "virtualization/virtualmachine.html",
@@ -123,10 +143,9 @@ TEMPLATE_REPLACEMENTS = {
123
143
  "wireless/supporteddatarate_retrieve.html",
124
144
  "wireless/wirelessnetwork_retrieve.html",
125
145
  ],
126
- "ipam/prefix_retrieve.html": ["ipam/prefix.html"],
146
+ "ipam/prefix_create.html": ["ipam/prefix_edit.html"],
127
147
  "ipam/vlan_update.html": ["ipam/vlan_edit.html"],
128
148
  "tenancy/tenant_create.html": ["tenancy/tenant_edit.html"],
129
- "tenancy/tenantgroup_retrieve.html": ["tenancy/tenantgroup.html"],
130
149
  "virtualchassis_update.html": ["dcim/virtualchassis_edit.html"],
131
150
  "virtualization/virtualmachine_update.html": ["virtualization/virtualmachine_edit.html"],
132
151
  }
nautobot/core/filters.py CHANGED
@@ -91,6 +91,10 @@ class MultiValueDateTimeFilter(django_filters.DateTimeFilter, django_filters.Mul
91
91
  class MultiValueNumberFilter(django_filters.NumberFilter, django_filters.MultipleChoiceFilter):
92
92
  field_class = multivalue_field_factory(django_forms.IntegerField)
93
93
 
94
+ def __init__(self, *args, choices=None, **kwargs):
95
+ super().__init__(*args, **kwargs)
96
+ self.choices = list(choices) if choices is not None else None
97
+
94
98
 
95
99
  class MultiValueBigNumberFilter(MultiValueNumberFilter):
96
100
  """Subclass of MultiValueNumberFilter used for BigInteger model fields."""
@@ -68,6 +68,7 @@ from nautobot.core.forms.widgets import (
68
68
  DatePicker,
69
69
  DateTimePicker,
70
70
  MultiValueCharInput,
71
+ NumberWithSelect,
71
72
  SelectWithDisabled,
72
73
  SelectWithPK,
73
74
  SlugWidget,
@@ -124,6 +125,7 @@ __all__ = (
124
125
  "MultiValueCharInput",
125
126
  "MultipleContentTypeField",
126
127
  "NullableDateField",
128
+ "NumberWithSelect",
127
129
  "NumericArrayField",
128
130
  "PrefixFieldMixin",
129
131
  "ReturnURLForm",
@@ -6,7 +6,7 @@ from django import forms
6
6
  from django.forms.models import ModelChoiceIterator
7
7
  from django.urls import get_script_prefix
8
8
 
9
- from nautobot.core import choices
9
+ from nautobot.core import choices as core_choices
10
10
  from nautobot.core.forms import utils
11
11
 
12
12
  __all__ = (
@@ -19,6 +19,7 @@ __all__ = (
19
19
  "ContentTypeSelect",
20
20
  "DatePicker",
21
21
  "DateTimePicker",
22
+ "NumberWithSelect",
22
23
  "SelectWithDisabled",
23
24
  "SelectWithPK",
24
25
  "SlugWidget",
@@ -68,7 +69,7 @@ class ColorSelect(forms.Select):
68
69
  option_template_name = "widgets/colorselect_option.html"
69
70
 
70
71
  def __init__(self, *args, **kwargs):
71
- kwargs["choices"] = utils.add_blank_choice(choices.ColorChoices)
72
+ kwargs["choices"] = utils.add_blank_choice(core_choices.ColorChoices)
72
73
  super().__init__(*args, **kwargs)
73
74
  self.attrs["class"] = "nautobot-select2-color-picker"
74
75
 
@@ -282,3 +283,21 @@ class ClearableFileInput(forms.ClearableFileInput):
282
283
 
283
284
  class Media:
284
285
  js = ["bootstrap-filestyle-1.2.3/bootstrap-filestyle.min.js"]
286
+
287
+
288
+ class NumberWithSelect(forms.NumberInput):
289
+ template_name = "widgets/number_input_with_choices.html"
290
+
291
+ def __init__(self, choices=None, *args, **kwargs):
292
+ super().__init__(*args, **kwargs)
293
+ if choices is None:
294
+ self.choices = []
295
+ elif hasattr(choices, "CHOICES"):
296
+ self.choices = core_choices.unpack_grouped_choices(choices.CHOICES)
297
+ else:
298
+ self.choices = core_choices.unpack_grouped_choices(choices)
299
+
300
+ def get_context(self, name, value, attrs):
301
+ context = super().get_context(name, value, attrs)
302
+ context["widget"]["choices"] = self.choices
303
+ return context
nautobot/core/settings.py CHANGED
@@ -822,6 +822,11 @@ CONSTANCE_CONFIG = {
822
822
  help_text="Whether to prefer IPv4 primary addresses over IPv6 primary addresses for devices.",
823
823
  field_type=bool,
824
824
  ),
825
+ "RACK_DEFAULT_U_HEIGHT": ConstanceConfigItem(
826
+ default=42,
827
+ help_text="Default height in rack units (U) for newly created racks. Must be between 1 and 500.",
828
+ field_type=int,
829
+ ),
825
830
  "RACK_ELEVATION_DEFAULT_UNIT_HEIGHT": ConstanceConfigItem(
826
831
  default=22, help_text="Default height (in pixels) of a rack unit in a rack elevation diagram", field_type=int
827
832
  ),
@@ -865,6 +870,7 @@ CONSTANCE_CONFIG_FIELDSETS = {
865
870
  "Pagination": ["PAGINATE_COUNT", "MAX_PAGE_SIZE", "PER_PAGE_DEFAULTS"],
866
871
  "Performance": ["JOB_CREATE_FILE_MAX_SIZE"],
867
872
  "Rack Elevation Rendering": [
873
+ "RACK_DEFAULT_U_HEIGHT",
868
874
  "RACK_ELEVATION_DEFAULT_UNIT_HEIGHT",
869
875
  "RACK_ELEVATION_DEFAULT_UNIT_WIDTH",
870
876
  "RACK_ELEVATION_UNIT_TWO_DIGIT_FORMAT",
@@ -0,0 +1,44 @@
1
+ <div class="input-group">
2
+ {% include 'django/forms/widgets/number.html' %}
3
+ {% if widget.choices %}
4
+ <span class="input-group-btn">
5
+ <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
6
+ <span class="caret"></span>
7
+ </button>
8
+ <ul class="dropdown-menu dropdown-menu-right">
9
+ {% for value, label in widget.choices %}
10
+ <li><a href="#" data-name="{{ widget.name }}" data-value="{{ value }}" class="set_value">{{ label }}</a></li>
11
+ {% endfor %}
12
+ </ul>
13
+ </span>
14
+ {% endif %}
15
+ </div>
16
+
17
+ {% if widget.choices %}
18
+ <script type="text/javascript">
19
+ (function() {
20
+ if (window.__nbNumberWithSelectWidgetBound) return;
21
+ window.__nbNumberWithSelectWidgetBound = true;
22
+ function bindNumberWithSelectHandler() {
23
+ document.addEventListener("click", function(e) {
24
+ if (!e.target) return;
25
+ var link = e.target.closest && e.target.closest("a.set_value");
26
+ if (!link) return;
27
+ e.preventDefault();
28
+ var container = link.closest(".input-group");
29
+ var name = link.getAttribute("data-name");
30
+ var value = link.getAttribute("data-value");
31
+ var input = container && name ? container.querySelector('input[name="' + name + '"]') : null;
32
+ if (input) {
33
+ input.value = value;
34
+ }
35
+ });
36
+ }
37
+ if (document.readyState === 'loading') {
38
+ document.addEventListener('DOMContentLoaded', bindNumberWithSelectHandler);
39
+ } else {
40
+ bindNumberWithSelectHandler();
41
+ }
42
+ })();
43
+ </script>
44
+ {% endif %}
@@ -393,17 +393,19 @@ def humanize_speed(speed):
393
393
  1544 => "1.544 Mbps"
394
394
  100000 => "100 Mbps"
395
395
  10000000 => "10 Gbps"
396
+ 1000000000 => "1 Tbps"
397
+ 1600000000 => "1.6 Tbps"
398
+ 10000000000 => "10 Tbps"
396
399
  """
397
400
  if not speed:
398
401
  return ""
399
- if speed >= 1000000000 and speed % 1000000000 == 0:
400
- return f"{int(speed / 1000000000)} Tbps"
401
- elif speed >= 1000000 and speed % 1000000 == 0:
402
- return f"{int(speed / 1000000)} Gbps"
403
- elif speed >= 1000 and speed % 1000 == 0:
404
- return f"{int(speed / 1000)} Mbps"
402
+
403
+ if speed >= 1000000000:
404
+ return f"{speed / 1000000000:g} Tbps"
405
+ elif speed >= 1000000:
406
+ return f"{speed / 1000000:g} Gbps"
405
407
  elif speed >= 1000:
406
- return f"{float(speed) / 1000} Mbps"
408
+ return f"{speed / 1000:g} Mbps"
407
409
  else:
408
410
  return f"{speed} Kbps"
409
411
 
@@ -10,6 +10,9 @@ from nautobot.core.settings_funcs import parse_redis_connection
10
10
 
11
11
  ALLOWED_HOSTS = ["nautobot.example.com"]
12
12
 
13
+ # Do *not* send anonymized install metrics when migration or post_upgrade management commands are run while testing
14
+ INSTALLATION_METRICS_ENABLED = False
15
+
13
16
  # Discover test jobs from within the Nautobot source code
14
17
  JOBS_ROOT = os.path.join(
15
18
  os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "extras", "test_jobs"
@@ -180,7 +180,13 @@ class NautobotTemplatetagsHelperTest(TestCase):
180
180
  def test_humanize_speed(self):
181
181
  self.assertEqual(helpers.humanize_speed(1544), "1.544 Mbps")
182
182
  self.assertEqual(helpers.humanize_speed(100000), "100 Mbps")
183
+ self.assertEqual(helpers.humanize_speed(2500000), "2.5 Gbps")
183
184
  self.assertEqual(helpers.humanize_speed(10000000), "10 Gbps")
185
+ self.assertEqual(helpers.humanize_speed(100000000), "100 Gbps")
186
+ self.assertEqual(helpers.humanize_speed(1000000000), "1 Tbps")
187
+ self.assertEqual(helpers.humanize_speed(1600000000), "1.6 Tbps")
188
+ self.assertEqual(helpers.humanize_speed(10000000000), "10 Tbps")
189
+ self.assertEqual(helpers.humanize_speed(100000000000), "100 Tbps")
184
190
 
185
191
  def test_tzoffset(self):
186
192
  self.assertTrue(callable(helpers.tzoffset))
@@ -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"