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
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
@@ -31,11 +31,14 @@ class BulkEditObjects(Job):
31
31
  model=ContentType,
32
32
  description="Type of objects to update",
33
33
  )
34
+ # The names of the job inputs must match the parameters of `key_params` and get_bulk_queryset_from_view
35
+ # This may be confusing for the saved_view_id since the job input is an ObjectVar but the key_param is a PK
36
+ # But it is the lesser of two evils.
34
37
  form_data = JSONVar(description="BulkEditForm data")
35
38
  pk_list = JSONVar(description="List of objects pks to edit", required=False)
36
39
  edit_all = BooleanVar(description="Bulk Edit all object / all filtered objects", required=False)
37
40
  filter_query_params = JSONVar(label="Filter Query Params", required=False)
38
- saved_view = ObjectVar(model=SavedView, required=False)
41
+ saved_view_id = ObjectVar(model=SavedView, required=False)
39
42
 
40
43
  class Meta:
41
44
  name = "Bulk Edit Objects"
@@ -127,9 +130,9 @@ class BulkEditObjects(Job):
127
130
  raise RunJobTaskFailed("Bulk Edit not fully successful, see logs")
128
131
 
129
132
  def run( # pylint: disable=arguments-differ
130
- self, *, content_type, form_data, pk_list=None, edit_all=False, filter_query_params=None, saved_view=None
133
+ self, *, content_type, form_data, pk_list=None, edit_all=False, filter_query_params=None, saved_view_id=None
131
134
  ):
132
- saved_view_id = saved_view.pk if saved_view is not None else None
135
+ saved_view_id = saved_view_id.pk if saved_view_id is not None else None
133
136
  if not filter_query_params:
134
137
  filter_query_params = {}
135
138
 
@@ -186,10 +189,13 @@ class BulkDeleteObjects(Job):
186
189
  model=ContentType,
187
190
  description="Type of objects to delete",
188
191
  )
192
+ # The names of the job inputs must match the parameters of `key_params` and get_bulk_queryset_from_view
193
+ # This may be confusing for the saved_view_id since the job input is an ObjectVar but the key_param is a PK
194
+ # But it is the lesser of two evils.
189
195
  pk_list = JSONVar(description="List of objects pks to delete", required=False)
190
196
  delete_all = BooleanVar(description="Delete all (filtered) objects instead of a list of PKs", required=False)
191
197
  filter_query_params = JSONVar(label="Filter Query Params", required=False)
192
- saved_view = ObjectVar(model=SavedView, required=False)
198
+ saved_view_id = ObjectVar(model=SavedView, required=False)
193
199
 
194
200
  class Meta:
195
201
  name = "Bulk Delete Objects"
@@ -200,9 +206,9 @@ class BulkDeleteObjects(Job):
200
206
  hidden = True
201
207
 
202
208
  def run( # pylint: disable=arguments-differ
203
- self, *, content_type, pk_list=None, delete_all=False, filter_query_params=None, saved_view=None
209
+ self, *, content_type, pk_list=None, delete_all=False, filter_query_params=None, saved_view_id=None
204
210
  ):
205
- saved_view_id = saved_view.pk if saved_view is not None else None
211
+ saved_view_id = saved_view_id.pk if saved_view_id is not None else None
206
212
  if not filter_query_params:
207
213
  filter_query_params = {}
208
214
  if not self.user.has_perm(f"{content_type.app_label}.delete_{content_type.model}"):
@@ -1,7 +1,7 @@
1
1
  from datetime import timedelta
2
2
 
3
3
  from django.core.exceptions import PermissionDenied
4
- from django.db.models import CASCADE
4
+ from django.db.models import CASCADE, PROTECT
5
5
  from django.db.models.signals import pre_delete
6
6
  from django.utils import timezone
7
7
 
@@ -67,6 +67,18 @@ class LogsCleanup(Job):
67
67
  cascade_queryset = related_model.objects.filter(**{f"{related_field_name}__id__in": queryset})
68
68
  if cascade_queryset.exists():
69
69
  self.recursive_delete_with_cascade(cascade_queryset, deletion_summary)
70
+ elif related_object.on_delete is PROTECT:
71
+ self.logger.warning(
72
+ "Skipping %s records with a protected relationship to %s."
73
+ " You must delete the related object(s) first.",
74
+ queryset.model._meta.label,
75
+ related_object.related_model._meta.label,
76
+ )
77
+ items_to_exclude = related_object.related_model.objects.values_list(
78
+ related_object.field.name, flat=True
79
+ )
80
+ queryset = queryset.exclude(id__in=items_to_exclude)
81
+ deletion_summary.update({related_object.related_model._meta.label: 0})
70
82
 
71
83
  genericrelation_related_fields = [
72
84
  field for field in queryset.model._meta.private_fields if hasattr(field, "bulk_related_objects")
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",
@@ -123,7 +123,17 @@ def setup_structlog_logging(
123
123
  return
124
124
 
125
125
  django_apps.append("django_structlog")
126
- django_middleware.append("django_structlog.middlewares.RequestMiddleware")
126
+
127
+ # Insert the middleware ahead of django_prometheus.middleware.PrometheusAfterMiddleware, which consumes the request.
128
+ # If that middleware is not present, append it at the end.
129
+ django_structlog_middleware = "django_structlog.middlewares.RequestMiddleware"
130
+ try:
131
+ index_of_prometheus_after_middleware = django_middleware.index(
132
+ "django_prometheus.middleware.PrometheusAfterMiddleware"
133
+ )
134
+ django_middleware.insert(index_of_prometheus_after_middleware, django_structlog_middleware)
135
+ except ValueError:
136
+ django_middleware.append(django_structlog_middleware)
127
137
 
128
138
  processors = (
129
139
  # Add the log level to the event dict under the level key.
@@ -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"
@@ -1060,6 +1060,70 @@ class BulkEditTestCase(TransactionTestCase):
1060
1060
  )
1061
1061
  self.assertEqual(IPAddress.objects.all().count(), IPAddress.objects.filter(status=active_status).count())
1062
1062
 
1063
+ def test_bulk_edit_objects_with_saved_view(self):
1064
+ """
1065
+ Bulk edit Status objects using a SavedView filter.
1066
+ """
1067
+ self.add_permissions("extras.change_status", "extras.view_status")
1068
+ saved_view = SavedView.objects.create(
1069
+ name="Save View for Statuses",
1070
+ owner=self.user,
1071
+ view="extras:status_list",
1072
+ config={"filter_params": {"name__isw": ["A"]}},
1073
+ )
1074
+
1075
+ # Confirm the SavedView filter matches some but not all Statuses
1076
+ self.assertTrue(
1077
+ 0 < saved_view.get_filtered_queryset(self.user).count() < Status.objects.exclude(color="aa1409").count()
1078
+ )
1079
+ delta_count = (
1080
+ Status.objects.exclude(color="aa1409").count() - saved_view.get_filtered_queryset(self.user).count()
1081
+ )
1082
+
1083
+ create_job_result_and_run_job(
1084
+ "nautobot.core.jobs.bulk_actions",
1085
+ "BulkEditObjects",
1086
+ username=self.user.username,
1087
+ content_type=self.status_ct.id,
1088
+ edit_all=True,
1089
+ filter_query_params={},
1090
+ pk_list=[],
1091
+ saved_view_id=saved_view.id,
1092
+ form_data={"color": "aa1409", "_all": "True"},
1093
+ )
1094
+
1095
+ self.assertEqual(delta_count, Status.objects.exclude(color="aa1409").count())
1096
+
1097
+ def test_bulk_edit_objects_with_saved_view_with_all_filters_removed(self):
1098
+ """
1099
+ Bulk edit Status objects using a SavedView filter but overwriting the saved field.
1100
+ """
1101
+ self.add_permissions("extras.change_status", "extras.view_status")
1102
+ saved_view = SavedView.objects.create(
1103
+ name="Save View for Statuses",
1104
+ owner=self.user,
1105
+ view="extras:status_list",
1106
+ config={"filter_params": {"name__isw": ["A"]}},
1107
+ )
1108
+
1109
+ self.assertTrue(
1110
+ 0 < saved_view.get_filtered_queryset(self.user).count() < Status.objects.exclude(color="aa1409").count()
1111
+ )
1112
+
1113
+ create_job_result_and_run_job(
1114
+ "nautobot.core.jobs.bulk_actions",
1115
+ "BulkEditObjects",
1116
+ username=self.user.username,
1117
+ content_type=self.status_ct.id,
1118
+ edit_all=True,
1119
+ filter_query_params={"all_filters_removed": [True]},
1120
+ pk_list=[],
1121
+ saved_view_id=saved_view.id,
1122
+ form_data={"color": "aa1409", "_all": "True"},
1123
+ )
1124
+
1125
+ self.assertEqual(0, Status.objects.exclude(color="aa1409").count())
1126
+
1063
1127
 
1064
1128
  class BulkDeleteTestCase(TransactionTestCase):
1065
1129
  """
@@ -1099,6 +1163,19 @@ class BulkDeleteTestCase(TransactionTestCase):
1099
1163
  circuit_type=circuit_type,
1100
1164
  status=statuses[0],
1101
1165
  )
1166
+ Circuit.objects.create(
1167
+ cid="Not Circuit",
1168
+ provider=provider,
1169
+ circuit_type=circuit_type,
1170
+ status=statuses[0],
1171
+ )
1172
+
1173
+ self.saved_view = SavedView.objects.create(
1174
+ name="Save View for Circuits",
1175
+ owner=self.user,
1176
+ view="circuits:circuit_list",
1177
+ config={"filter_params": {"cid__isw": "Circuit "}},
1178
+ )
1102
1179
 
1103
1180
  def _common_no_error_test_assertion(self, model, job_result, **filter_params):
1104
1181
  self.assertJobResultStatus(job_result)
@@ -1250,6 +1327,47 @@ class BulkDeleteTestCase(TransactionTestCase):
1250
1327
  )
1251
1328
  self._common_no_error_test_assertion(Role, job_result, name__istartswith="Example Status")
1252
1329
 
1330
+ def test_bulk_delete_objects_with_saved_view(self):
1331
+ """
1332
+ Delete objects using a SavedView filter.
1333
+ """
1334
+ self.add_permissions("circuits.delete_circuit", "circuits.view_circuit")
1335
+
1336
+ # we assert that the saved view filter actually filters some circuits and there are others not filtered out
1337
+ self.assertTrue(0 < self.saved_view.get_filtered_queryset(self.user).count() < Circuit.objects.all().count())
1338
+ create_job_result_and_run_job(
1339
+ "nautobot.core.jobs.bulk_actions",
1340
+ "BulkDeleteObjects",
1341
+ username=self.user.username,
1342
+ content_type=ContentType.objects.get_for_model(Circuit).id,
1343
+ delete_all=True,
1344
+ filter_query_params={},
1345
+ pk_list=[],
1346
+ saved_view_id=self.saved_view.id,
1347
+ )
1348
+ self.assertTrue(Circuit.objects.exists())
1349
+ self.assertFalse(self.saved_view.get_filtered_queryset(self.user).exists())
1350
+
1351
+ def test_bulk_delete_objects_with_saved_view_with_all_filters_removed(self):
1352
+ """
1353
+ Delete Objects using a SavedView filter, but ignore the filter if all_filters_removed is set.
1354
+ """
1355
+ self.add_permissions("circuits.delete_circuit", "circuits.view_circuit")
1356
+
1357
+ # we assert that the saved view filter actually filters some circuits and there are others not filtered out
1358
+ self.assertTrue(0 < self.saved_view.get_filtered_queryset(self.user).count() < Circuit.objects.all().count())
1359
+ create_job_result_and_run_job(
1360
+ "nautobot.core.jobs.bulk_actions",
1361
+ "BulkDeleteObjects",
1362
+ username=self.user.username,
1363
+ content_type=ContentType.objects.get_for_model(Circuit).id,
1364
+ delete_all=True,
1365
+ filter_query_params={"all_filters_removed": [True]},
1366
+ pk_list=[],
1367
+ saved_view_id=self.saved_view.id,
1368
+ )
1369
+ self.assertFalse(Circuit.objects.all().exists())
1370
+
1253
1371
 
1254
1372
  class RefreshDynamicGroupCacheJobButtonReceiverTestCase(TransactionTestCase):
1255
1373
  def setUp(self):
@@ -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))