nautobot 2.4.17__py3-none-any.whl → 2.4.18__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 (92) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
  3. nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
  4. nautobot/circuits/tests/integration/test_circuit.py +2 -2
  5. nautobot/circuits/views.py +32 -15
  6. nautobot/core/filters.py +2 -2
  7. nautobot/core/settings.py +1 -0
  8. nautobot/core/settings.yaml +9 -0
  9. nautobot/core/tables.py +21 -23
  10. nautobot/core/templates/components/breadcrumbs.html +19 -0
  11. nautobot/core/templates/generic/object_changelog.html +0 -2
  12. nautobot/core/templates/generic/object_list.html +15 -12
  13. nautobot/core/templates/generic/object_notes.html +0 -2
  14. nautobot/core/templates/generic/object_retrieve.html +16 -9
  15. nautobot/core/templatetags/helpers.py +24 -0
  16. nautobot/core/templatetags/ui_framework.py +40 -5
  17. nautobot/core/testing/filters.py +37 -21
  18. nautobot/core/testing/views.py +25 -0
  19. nautobot/core/tests/test_tables.py +43 -6
  20. nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
  21. nautobot/core/tests/test_titles.py +2 -2
  22. nautobot/core/tests/test_ui.py +14 -1
  23. nautobot/core/tests/test_views.py +45 -0
  24. nautobot/core/ui/breadcrumbs.py +13 -8
  25. nautobot/core/ui/object_detail.py +43 -5
  26. nautobot/core/ui/titles.py +9 -5
  27. nautobot/core/views/__init__.py +24 -3
  28. nautobot/core/views/generic.py +42 -17
  29. nautobot/core/views/mixins.py +146 -12
  30. nautobot/core/views/utils.py +117 -0
  31. nautobot/dcim/models/devices.py +4 -0
  32. nautobot/dcim/tables/__init__.py +2 -0
  33. nautobot/dcim/tables/devices.py +24 -0
  34. nautobot/dcim/tables/power.py +2 -2
  35. nautobot/dcim/templates/dcim/device/base.html +1 -11
  36. nautobot/dcim/templates/dcim/device_component.html +0 -19
  37. nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
  38. nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
  39. nautobot/dcim/tests/test_views.py +41 -0
  40. nautobot/dcim/views.py +160 -39
  41. nautobot/extras/filters/mixins.py +1 -1
  42. nautobot/extras/forms/forms.py +15 -0
  43. nautobot/extras/models/groups.py +10 -1
  44. nautobot/extras/models/jobs.py +2 -2
  45. nautobot/extras/plugins/views.py +18 -5
  46. nautobot/extras/tables.py +4 -2
  47. nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
  48. nautobot/extras/templates/extras/dynamicgroup.html +2 -99
  49. nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
  50. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
  51. nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
  52. nautobot/extras/templates/extras/gitrepository.html +2 -82
  53. nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
  54. nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
  55. nautobot/extras/templates/extras/gitrepository_update.html +13 -0
  56. nautobot/extras/templates/extras/note_retrieve.html +0 -52
  57. nautobot/extras/templates/extras/plugin_detail.html +3 -7
  58. nautobot/extras/templates/extras/plugins_list.html +0 -2
  59. nautobot/extras/tests/test_dynamicgroups.py +73 -18
  60. nautobot/extras/tests/test_views.py +5 -0
  61. nautobot/extras/urls.py +2 -94
  62. nautobot/extras/views.py +424 -430
  63. nautobot/ipam/querysets.py +3 -3
  64. nautobot/ipam/signals.py +6 -1
  65. nautobot/ipam/templates/ipam/prefix.html +0 -8
  66. nautobot/ipam/tests/test_api.py +5 -0
  67. nautobot/ipam/tests/test_models.py +387 -0
  68. nautobot/ipam/tests/test_querysets.py +46 -0
  69. nautobot/ipam/utils/migrations.py +1 -1
  70. nautobot/ipam/views.py +17 -8
  71. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
  72. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
  73. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
  74. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
  75. nautobot/project-static/docs/development/core/getting-started.html +0 -15
  76. nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
  77. nautobot/project-static/docs/objects.inv +0 -0
  78. nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
  79. nautobot/project-static/docs/search/search_index.json +1 -1
  80. nautobot/project-static/docs/sitemap.xml +300 -300
  81. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  82. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
  83. nautobot/project-static/img/nautobot_icon.svg +32 -34
  84. nautobot/project-static/js/table_sorting_indicator.js +0 -2
  85. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
  86. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
  87. nautobot/core/templates/inc/breadcrumbs.html +0 -14
  88. nautobot/project-static/docs/requirements.txt +0 -14
  89. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
  92. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/entry_points.txt +0 -0
@@ -18,8 +18,18 @@ class TableTestCase(TestCase):
18
18
  def _validate_sorted_tree_queryset_same_with_table_queryset(self, queryset, table_class, field_name):
19
19
  with self.subTest(f"Assert sorting {table_class.__name__} on '{field_name}'"):
20
20
  table = table_class(queryset.with_tree_fields(), order_by=field_name)
21
- table_queryset_data = table.data.data.values_list("pk", flat=True)
22
- sorted_queryset = queryset.with_tree_fields().extra(order_by=[field_name]).values_list("pk", flat=True)
21
+ table_queryset_data = table.data.data.values_list(field_name, flat=True)
22
+ sorted_queryset = (
23
+ queryset.with_tree_fields().extra(order_by=[field_name]).values_list(field_name, flat=True)
24
+ )
25
+ self.assertEqual(list(table_queryset_data), list(sorted_queryset))
26
+
27
+ with self.subTest(f"Assert sorting {table_class.__name__} on '-{field_name}'"):
28
+ table = table_class(queryset.with_tree_fields(), order_by=f"-{field_name}")
29
+ table_queryset_data = table.data.data.values_list(field_name, flat=True)
30
+ sorted_queryset = (
31
+ queryset.with_tree_fields().extra(order_by=[f"-{field_name}"]).values_list(field_name, flat=True)
32
+ )
23
33
  self.assertEqual(list(table_queryset_data), list(sorted_queryset))
24
34
 
25
35
  def test_tree_model_table_orderable(self):
@@ -61,14 +71,41 @@ class TableTestCase(TestCase):
61
71
  table_avail_fields = set(model_field_names) & set(table_class.Meta.fields)
62
72
  for table_field_name in table_avail_fields:
63
73
  self._validate_sorted_tree_queryset_same_with_table_queryset(queryset, table_class, table_field_name)
64
- self._validate_sorted_tree_queryset_same_with_table_queryset(
65
- queryset, table_class, f"-{table_field_name}"
66
- )
67
74
 
68
75
  # Test for `rack_count`
69
76
  queryset = RackGroupTable.Meta.model.objects.annotate(rack_count=count_related(Rack, "rack_group")).all()
70
77
  self._validate_sorted_tree_queryset_same_with_table_queryset(queryset, RackGroupTable, "rack_count")
71
- self._validate_sorted_tree_queryset_same_with_table_queryset(queryset, RackGroupTable, "-rack_count")
78
+
79
+ # https://github.com/nautobot/nautobot/issues/7330 - sorting by custom field
80
+ l1 = Location.objects.first()
81
+ l1._custom_field_data["example_app_auto_custom_field"] = "alpha"
82
+ l1.validated_save()
83
+ l2 = Location.objects.last()
84
+ l2._custom_field_data["example_app_auto_custom_field"] = "omega"
85
+ l2.validated_save()
86
+ table = LocationTable(
87
+ Location.objects.exclude(_custom_field_data__example_app_auto_custom_field="Default value")
88
+ )
89
+
90
+ table.order_by = ["cf_example_app_auto_custom_field"]
91
+ table_queryset_data = table.data.data.values_list("pk", flat=True)
92
+ sorted_queryset = (
93
+ Location.objects.with_tree_fields()
94
+ .exclude(_custom_field_data__example_app_auto_custom_field="Default value")
95
+ .extra(order_by=["_custom_field_data__example_app_auto_custom_field"])
96
+ .values_list("pk", flat=True)
97
+ )
98
+ self.assertEqual(list(table_queryset_data), list(sorted_queryset))
99
+
100
+ table.order_by = ["-cf_example_app_auto_custom_field"]
101
+ table_queryset_data = table.data.data.values_list("pk", flat=True)
102
+ sorted_queryset = (
103
+ Location.objects.with_tree_fields()
104
+ .exclude(_custom_field_data__example_app_auto_custom_field="Default value")
105
+ .extra(order_by=["-_custom_field_data__example_app_auto_custom_field"])
106
+ .values_list("pk", flat=True)
107
+ )
108
+ self.assertEqual(list(table_queryset_data), list(sorted_queryset))
72
109
 
73
110
  def test_base_table_apis(self):
74
111
  """
@@ -0,0 +1,146 @@
1
+ from django.template import Context
2
+
3
+ from nautobot.core.templatetags import ui_framework
4
+ from nautobot.core.testing import TestCase
5
+ from nautobot.core.ui.breadcrumbs import Breadcrumbs
6
+ from nautobot.core.ui.titles import Titles
7
+
8
+
9
+ class NautobotTemplatetagsUIComponentsTest(TestCase):
10
+ """Tests template tags from ui_framework module."""
11
+
12
+ # ---------------------------
13
+ # render_title
14
+ # ---------------------------
15
+
16
+ def test_render_title_with_legacy_title_present(self):
17
+ context = Context(
18
+ {
19
+ "title": "Custom Title",
20
+ "view_titles": Titles(),
21
+ "verbose_name_plural": "Default Title",
22
+ }
23
+ )
24
+ output = ui_framework.render_title(context)
25
+
26
+ self.assertEqual(output, "Custom Title")
27
+
28
+ def test_render_title_with_view_titles_only(self):
29
+ context = Context(
30
+ {
31
+ "view_titles": Titles(),
32
+ "verbose_name_plural": "Default Title",
33
+ }
34
+ )
35
+ output = ui_framework.render_title(context)
36
+
37
+ self.assertEqual(output, "Default Title")
38
+
39
+ def test_render_title_with_invalid_view_titles(self):
40
+ class MyStuff:
41
+ pass
42
+
43
+ context = Context(
44
+ {
45
+ "view_titles": MyStuff(),
46
+ "verbose_name_plural": "Default Title",
47
+ }
48
+ )
49
+ output = ui_framework.render_title(context)
50
+
51
+ self.assertEqual(output, "")
52
+
53
+ def test_render_title_with_empty_context(self):
54
+ context = Context({})
55
+ output = ui_framework.render_title(context)
56
+
57
+ self.assertEqual(output, "")
58
+
59
+ # ---------------------------
60
+ # render_breadcrumbs
61
+ # ---------------------------
62
+
63
+ def test_render_breadcrumbs(self):
64
+ context = Context(
65
+ {
66
+ "list_url": "home",
67
+ "title": "New Home",
68
+ "view_action": "list",
69
+ "breadcrumbs": Breadcrumbs(),
70
+ }
71
+ )
72
+ output = ui_framework.render_breadcrumbs(context)
73
+
74
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">New Home</a></li></ol>')
75
+
76
+ def test_render_breadcrumbs_empty_context(self):
77
+ context = Context({})
78
+ output = ui_framework.render_breadcrumbs(context)
79
+
80
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"></ol>')
81
+
82
+ def test_render_breadcrumbs_with_legacy_breadcrumbs(self):
83
+ legacy_breadcrumbs = '<li><a href="/">Home</a></li>'
84
+ context = Context({})
85
+ output = ui_framework.render_breadcrumbs(context, legacy_breadcrumbs)
86
+
87
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">Home</a></li></ol>')
88
+
89
+ def test_render_breadcrumbs_with_legacy_and_block_breadcrumbs_the_same_with_breadcrumbs_class(self):
90
+ legacy_breadcrumbs = block_breadcrumbs = '<li><a href="/">Home</a></li>'
91
+ context = Context(
92
+ {
93
+ "list_url": "home",
94
+ "title": "New Home",
95
+ "view_action": "list",
96
+ "breadcrumbs": Breadcrumbs(),
97
+ }
98
+ )
99
+ output = ui_framework.render_breadcrumbs(context, legacy_breadcrumbs, block_breadcrumbs)
100
+
101
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">New Home</a></li></ol>')
102
+
103
+ def test_render_breadcrumbs_with_legacy_and_block_breadcrumbs_the_same_and_no_breadcrumbs_class(self):
104
+ legacy_breadcrumbs = block_breadcrumbs = '<li><a href="/">Home</a></li>'
105
+ context = Context({})
106
+ output = ui_framework.render_breadcrumbs(context, legacy_breadcrumbs, block_breadcrumbs)
107
+
108
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">Home</a></li></ol>')
109
+
110
+ def test_render_breadcrumbs_with_legacy_breadcrumbs_override(self):
111
+ legacy_breadcrumbs = '<li><a href="/">Home</a></li>'
112
+ block_breadcrumbs = '<li><a href="/">Override</a></li>'
113
+ context = Context(
114
+ {
115
+ "list_url": "home",
116
+ "title": "New Home",
117
+ "view_action": "list",
118
+ "breadcrumbs": Breadcrumbs(),
119
+ }
120
+ )
121
+ output = ui_framework.render_breadcrumbs(context, legacy_breadcrumbs, block_breadcrumbs)
122
+
123
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">Override</a></li></ol>')
124
+
125
+ def test_render_breadcrumbs_strips_tags(self):
126
+ legacy_breadcrumbs = """
127
+ <li>
128
+ <a href="/">Home</a>
129
+ </li>"""
130
+
131
+ block_breadcrumbs = """<li>
132
+
133
+ <a href="/">Home</a></li>
134
+ """
135
+
136
+ context = Context(
137
+ {
138
+ "list_url": "home",
139
+ "title": "New Home",
140
+ "view_action": "list",
141
+ "breadcrumbs": Breadcrumbs(),
142
+ }
143
+ )
144
+ output = ui_framework.render_breadcrumbs(context, legacy_breadcrumbs, block_breadcrumbs)
145
+
146
+ self.assertHTMLEqual(output, '<ol class="breadcrumb"><li><a href="/">New Home</a></li></ol>')
@@ -163,7 +163,7 @@ class TitlesTestCase(TestCase):
163
163
  """Test rendering when no view_action is provided."""
164
164
  context = Context({"verbose_name_plural": "devices"})
165
165
  result = self.titles.render(context)
166
- self.assertEqual(result, "Devices") # Should use list_action as default
166
+ self.assertEqual(result, "Devices") # Should use * action as default
167
167
 
168
168
  def test_get_extra_context(self):
169
169
  """Test that get_extra_context returns empty dict by default."""
@@ -173,7 +173,7 @@ class TitlesTestCase(TestCase):
173
173
 
174
174
  def test_get_extra_context_is_used_during_render(self):
175
175
  """Test that get_extra_context is being used to extend the context."""
176
- context = Context({})
176
+ context = Context({"view_action": "list"})
177
177
 
178
178
  class TitlesSubClass(Titles):
179
179
  def get_extra_context(self, context: Context) -> dict:
@@ -7,7 +7,7 @@ from django.test import RequestFactory
7
7
 
8
8
  from nautobot.core.templatetags.helpers import HTML_NONE
9
9
  from nautobot.core.testing import TestCase
10
- from nautobot.core.ui.object_detail import BaseTextPanel, DataTablePanel, ObjectsTablePanel, Panel
10
+ from nautobot.core.ui.object_detail import BaseTextPanel, DataTablePanel, ObjectFieldsPanel, ObjectsTablePanel, Panel
11
11
  from nautobot.dcim.models import DeviceRedundancyGroup
12
12
  from nautobot.dcim.tables.devices import DeviceTable
13
13
 
@@ -68,6 +68,19 @@ class DataTablePanelTest(TestCase):
68
68
  )
69
69
 
70
70
 
71
+ class ObjectFieldsPanelTest(TestCase):
72
+ def test_get_data_ignore_nonexistent_fields(self):
73
+ panel = ObjectFieldsPanel(weight=100, fields=["name", "foo", "bar"], ignore_nonexistent_fields=True)
74
+ redundancy_group = DeviceRedundancyGroup.objects.first()
75
+ context = Context({"object": redundancy_group})
76
+ data = panel.get_data(context)
77
+ self.assertEqual(data, {"name": redundancy_group.name}) # no keys for nonexistent fields
78
+
79
+ panel = ObjectFieldsPanel(weight=100, fields=["name", "foo", "bar"], ignore_nonexistent_fields=False)
80
+ with self.assertRaises(AttributeError):
81
+ data = panel.get_data(context)
82
+
83
+
71
84
  class BaseTextPanelTest(TestCase):
72
85
  def test_init_set_object_params(self):
73
86
  # Test default settings
@@ -8,6 +8,7 @@ import urllib.parse
8
8
  from django.apps import apps
9
9
  from django.conf import settings
10
10
  from django.contrib.contenttypes.models import ContentType
11
+ from django.core.cache import cache
11
12
  from django.core.files.uploadedfile import SimpleUploadedFile
12
13
  from django.test import override_settings, RequestFactory
13
14
  from django.test.utils import override_script_prefix
@@ -23,6 +24,7 @@ from nautobot.core.testing.utils import extract_page_body
23
24
  from nautobot.core.utils.permissions import get_permission_for_model
24
25
  from nautobot.core.views import NautobotMetricsView
25
26
  from nautobot.core.views.mixins import GetReturnURLMixin
27
+ from nautobot.core.views.utils import METRICS_CACHE_KEY
26
28
  from nautobot.dcim.models.locations import Location
27
29
  from nautobot.extras.choices import CustomFieldTypeChoices
28
30
  from nautobot.extras.models import FileProxy, Status
@@ -566,6 +568,49 @@ class MetricsViewTestCase(TestCase):
566
568
  metric_names_with_app.remove(test_metric_name)
567
569
  self.assertSetEqual(metric_names_with_app, metric_names_without_app)
568
570
 
571
+ def test_enabled_metrics_cache_disabled(self):
572
+ """Assert that when cache is disabled the cache enabled function doesn't get called."""
573
+ with mock.patch("nautobot.core.views.utils.generate_latest_with_cache") as mock_generate_latest_with_cache:
574
+ self.query_and_parse_metrics()
575
+ self.assertTrue(mock_generate_latest_with_cache.call_count == 0)
576
+
577
+ @override_settings(METRICS_EXPERIMENTAL_CACHING_DURATION=30)
578
+ def test_enabled_metrics_cache_enabled(self):
579
+ """Assert that multiple calls to metrics with caching returns expected response."""
580
+ test_metric_name = "nautobot_example_metric_count"
581
+ metrics_with_app = self.query_and_parse_metrics()
582
+ metrics_with_app_cached = self.query_and_parse_metrics()
583
+ metric_names_with_app = {metric.name for metric in metrics_with_app}
584
+ metric_names_with_app_cached = {metric.name for metric in metrics_with_app_cached}
585
+ self.assertIn(test_metric_name, metric_names_with_app)
586
+ self.assertIn(test_metric_name, metric_names_with_app_cached)
587
+
588
+ # In some circumstances (e.g. if this test is run first) metrics_with_app may not have
589
+ # metrics from Django like total view counts. Since metrics_with_app_cached is called
590
+ # second, it should have everything that metrics_with_app has (plus potentially more).
591
+ # We at least want to ensure the cached version has everything the non-cached version has.
592
+ self.assertTrue(
593
+ metric_names_with_app.issubset(metric_names_with_app_cached),
594
+ msg="Cached metrics should be a superset of non-cached metrics.",
595
+ )
596
+ with mock.patch("nautobot.core.views.generate_latest_with_cache") as mock_generate_latest_with_cache:
597
+ self.query_and_parse_metrics()
598
+ self.query_and_parse_metrics()
599
+ self.assertEqual(mock_generate_latest_with_cache.call_count, 2)
600
+
601
+ from example_app.metrics import metric_example
602
+
603
+ cache.delete(METRICS_CACHE_KEY) # Ensure we start with a clean cache for the next part of the test
604
+ # Assert that the metric function only gets called once even though we scrape metrics twice
605
+
606
+ mock_metric_function = mock.Mock(name="mock_metric_function", side_effect=metric_example)
607
+ with mock.patch.dict("nautobot.core.views.registry", app_metrics=[mock_metric_function]):
608
+ self.query_and_parse_metrics()
609
+ first_call_count = mock_metric_function.call_count
610
+ self.query_and_parse_metrics()
611
+ second_call_count = mock_metric_function.call_count
612
+ self.assertEqual(first_call_count, second_call_count)
613
+
569
614
 
570
615
  class AuthenticateMetricsTestCase(APITestCase):
571
616
  def test_metrics_authentication(self):
@@ -192,9 +192,14 @@ class ViewNameBreadcrumbItem(BaseBreadcrumbItem):
192
192
 
193
193
  def get_label(self, context: Context) -> str:
194
194
  if self.label_from_view_name:
195
- model = get_model_for_view_name(self.get_view_name(context))
196
- if model is not None:
197
- return model._meta.verbose_name_plural
195
+ try:
196
+ model = get_model_for_view_name(self.get_view_name(context))
197
+ if model is not None:
198
+ return model._meta.verbose_name_plural
199
+ except ValueError:
200
+ # `get_model_for_view_name` is not working properly with some proper paths like "home"
201
+ # and because by default we're trying to resolve label by using `list_url` this error may occur in some apps
202
+ pass
198
203
  return super().get_label(context)
199
204
 
200
205
  def get_view_name(self, context: Context) -> Optional[str]:
@@ -401,6 +406,7 @@ class Breadcrumbs:
401
406
  # Default breadcrumb if view defines `list_url` in the Context
402
407
  ViewNameBreadcrumbItem(
403
408
  view_name_key="list_url",
409
+ label_key="title",
404
410
  label_from_view_name=True,
405
411
  should_render=lambda context: context.get("list_url") is not None,
406
412
  ),
@@ -411,7 +417,7 @@ class Breadcrumbs:
411
417
  def __init__(
412
418
  self,
413
419
  items: BreadcrumbItemsType = None,
414
- template: str = "inc/breadcrumbs.html",
420
+ template: str = "components/breadcrumbs.html",
415
421
  ):
416
422
  """
417
423
  Initialize the Breadcrumbs configuration.
@@ -447,7 +453,7 @@ class Breadcrumbs:
447
453
  Returns:
448
454
  (list[tuple[str, str]]): A list of (url, label) tuples representing breadcrumb entries.
449
455
  """
450
- action = context.get("view_action", "list")
456
+ action = context.get("view_action", "")
451
457
  detail = context.get("detail", False)
452
458
  items = self.get_items_for_action(self.items, action, detail)
453
459
  return [item.as_pair(context) for item in items if item.should_render(context)]
@@ -457,7 +463,7 @@ class Breadcrumbs:
457
463
  Filters out all items that both label and url are None or empty str.
458
464
 
459
465
  Args:
460
- items (list[tuple[str, str]]): breadcrumb items pair.s
466
+ items (list[tuple[str, str]]): breadcrumb items pairs.
461
467
  context (Context): The view or template context.
462
468
 
463
469
  Returns:
@@ -481,8 +487,7 @@ class Breadcrumbs:
481
487
  @staticmethod
482
488
  def get_items_for_action(items: BreadcrumbItemsType, action: str, detail: bool) -> list[BaseBreadcrumbItem]:
483
489
  """
484
- Get the breadcrumb items for a specific action, with fallback to 'detail' if not found
485
- and to asterisk (*) if present.
490
+ Get the breadcrumb items for a specific action, 'detail' or to asterisk (*) if present.
486
491
 
487
492
  Args:
488
493
  items (BreadcrumbItemsType): Dictionary mapping action names to breadcrumb item lists.
@@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType
9
9
  from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
10
10
  from django.db import models
11
11
  from django.db.models import CharField, JSONField, Q, URLField
12
+ from django.db.models.constants import LOOKUP_SEP
12
13
  from django.db.models.fields.related import ManyToManyField
13
14
  from django.template import Context
14
15
  from django.template.defaultfilters import truncatechars
@@ -971,6 +972,7 @@ class KeyValueTablePanel(Panel):
971
972
  context_data_key=None,
972
973
  hide_if_unset=(),
973
974
  value_transforms=None,
975
+ key_transforms=None,
974
976
  body_wrapper_template_path="components/panel/body_wrapper_key_value_table.html",
975
977
  **kwargs,
976
978
  ):
@@ -989,6 +991,8 @@ class KeyValueTablePanel(Panel):
989
991
 
990
992
  - `[render_markdown, placeholder]` - render the given text as Markdown, or render a placeholder if blank
991
993
  - `[humanize_speed, placeholder]` - convert the given kbps value to Mbps or Gbps for display
994
+ key_transforms (dict, optional): A mapping of original field names to custom display names to be used when rendering keys
995
+ For example: {'content_types': 'Content Type'}.
992
996
  """
993
997
  if data and context_data_key:
994
998
  raise ValueError("The data and context_data_key parameters are mutually exclusive")
@@ -996,6 +1000,7 @@ class KeyValueTablePanel(Panel):
996
1000
  self.context_data_key = context_data_key or "data"
997
1001
  self.hide_if_unset = hide_if_unset
998
1002
  self.value_transforms = value_transforms or {}
1003
+ self.key_transforms = key_transforms or {}
999
1004
  super().__init__(body_wrapper_template_path=body_wrapper_template_path, **kwargs)
1000
1005
 
1001
1006
  def should_render(self, context: Context):
@@ -1176,6 +1181,7 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1176
1181
  self,
1177
1182
  *,
1178
1183
  fields="__all__",
1184
+ additional_fields=(),
1179
1185
  exclude_fields=(),
1180
1186
  context_object_key=None,
1181
1187
  ignore_nonexistent_fields=False,
@@ -1189,6 +1195,11 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1189
1195
  fields (str, list): The ordered list of fields to display, or `"__all__"` to display fields automatically.
1190
1196
  Note that ManyToMany fields and reverse relations are **not** included in `"__all__"` at this time, nor
1191
1197
  are any hidden fields, nor the specially handled `id`, `created`, `last_updated` fields on most models.
1198
+ When a list is specified, it may include model `@property` attributes and nested lookups (if desired) in
1199
+ addition to concrete model fields; when using `fields="__all__"`, such additional attributes and lookups
1200
+ may be specified with the `additional_fields` parameter.
1201
+ additional_fields (list): Only relevant if `fields == "__all__"`, in which case it can specify additional
1202
+ non-default fields to include such as reverse relations, `@property` attributes, nested lookups, etc.
1192
1203
  exclude_fields (list): Only relevant if `fields == "__all__"`, in which case it excludes the given fields.
1193
1204
  context_object_key (str): The key in the render context that will contain the object to derive fields from.
1194
1205
  ignore_nonexistent_fields (bool): If True, `fields` is permitted to include field names that don't actually
@@ -1197,6 +1208,11 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1197
1208
  (see `render_label()`).
1198
1209
  """
1199
1210
  self.fields = fields
1211
+ if additional_fields and fields != "__all__":
1212
+ raise ValueError("additional_fields may only be used in combination with fields='__all__'")
1213
+ self.additional_fields = additional_fields
1214
+ if exclude_fields and fields != "__all__":
1215
+ raise ValueError("exclude_fields may only be used in combination with fields='__all__'")
1200
1216
  self.exclude_fields = exclude_fields
1201
1217
  self.context_object_key = context_object_key
1202
1218
  self.ignore_nonexistent_fields = ignore_nonexistent_fields
@@ -1210,6 +1226,7 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1210
1226
 
1211
1227
  def render_value(self, key, value, context: Context):
1212
1228
  obj = get_obj_from_context(context, self.context_object_key)
1229
+ # TODO: handle nested keys, e.g. device_type__device_family
1213
1230
  try:
1214
1231
  field_instance = obj._meta.get_field(key)
1215
1232
  except FieldDoesNotExist:
@@ -1230,8 +1247,18 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1230
1247
  if isinstance(field_instance, JSONField):
1231
1248
  return format_html("<pre>{}</pre>", render_json(value))
1232
1249
 
1233
- if isinstance(field_instance, ManyToManyField) and field_instance.related_model == ContentType:
1234
- return render_content_types(value)
1250
+ if isinstance(field_instance, ManyToManyField):
1251
+ if field_instance.related_model == ContentType:
1252
+ return render_content_types(value)
1253
+ # TODO: this would be nice but it's probably too error-prone in general:
1254
+ # return render_m2m(
1255
+ # value.all(),
1256
+ # (
1257
+ # reverse(get_route_for_model(field_instance.related_model, "list")) + "?" +
1258
+ # obj._meta.verbose_name_plural.lower().replace(" ", "_") + "=" + str(obj.pk)
1259
+ # ),
1260
+ # key,
1261
+ # )
1235
1262
 
1236
1263
  if isinstance(field_instance, CharField) and hasattr(obj, f"get_{key}_display"):
1237
1264
  # For example, Secret.provider -> Secret.get_provider_display()
@@ -1273,6 +1300,8 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1273
1300
  # TODO: apply a default ordering "smarter" than declaration order? Alphabetical? By field type?
1274
1301
  # TODO: allow model to specify an alternative field ordering?
1275
1302
 
1303
+ fields += self.additional_fields
1304
+
1276
1305
  data = {}
1277
1306
 
1278
1307
  if isinstance(instance, TreeModel) and (self.fields == "__all__" or "_hierarchy" in self.fields):
@@ -1283,9 +1312,14 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1283
1312
  if field_name in self.exclude_fields:
1284
1313
  continue
1285
1314
  try:
1286
- field_value = getattr(instance, field_name)
1287
- except ObjectDoesNotExist:
1288
- field_value = None
1315
+ field_value = instance
1316
+ # Handle nested lookups, e.g. device_type__device_family
1317
+ for token in field_name.split(LOOKUP_SEP):
1318
+ try:
1319
+ field_value = getattr(field_value, token)
1320
+ except ObjectDoesNotExist:
1321
+ field_value = None
1322
+ break
1289
1323
  except AttributeError:
1290
1324
  if self.ignore_nonexistent_fields:
1291
1325
  continue
@@ -1301,6 +1335,10 @@ class ObjectFieldsPanel(KeyValueTablePanel):
1301
1335
 
1302
1336
  def render_key(self, key, value, context: Context):
1303
1337
  """Render the `verbose_name` of the model field whose name corresponds to the given key, if applicable."""
1338
+
1339
+ if key in self.key_transforms:
1340
+ return self.key_transforms[key]
1341
+
1304
1342
  instance = get_obj_from_context(context, self.context_object_key)
1305
1343
 
1306
1344
  if instance is not None:
@@ -1,9 +1,10 @@
1
- from typing import Literal, Optional
1
+ from typing import Literal, Optional, Union
2
2
 
3
3
  from django.template import Context, Template
4
4
  from django.utils.html import strip_tags
5
5
 
6
6
  DEFAULT_TITLES: dict[str, str] = {
7
+ "*": "{{ verbose_name_plural|bettertitle }}",
7
8
  "list": "{{ verbose_name_plural|bettertitle }}",
8
9
  "detail": "{{ object.display|default:object }}",
9
10
  "retrieve": "{{ object.display|default:object }}",
@@ -59,7 +60,7 @@ class Titles:
59
60
  if template_plugins:
60
61
  self.template_plugins.extend(template_plugins)
61
62
 
62
- def render(self, context: Context, mode: ModeType = "html") -> str:
63
+ def render(self, context: Union[dict, Context], mode: ModeType = "html") -> str:
63
64
  """
64
65
  Renders the title based on given context and current action.
65
66
 
@@ -68,12 +69,15 @@ class Titles:
68
69
  Make sure that needed context variables are in context and needed plugins are loaded.
69
70
 
70
71
  Args:
71
- context (Context): Render context.
72
+ context (Union[dict, Context]): Render context.
72
73
  mode (ModeType): Rendering mode: "html" or "plain".
73
74
 
74
75
  Returns:
75
76
  (str): HTML fragment or plain text, depending on `mode`.
76
77
  """
78
+ if isinstance(context, dict):
79
+ context = Context(context)
80
+
77
81
  with context.update(self.get_extra_context(context)):
78
82
  template_str = self.get_template_str(context)
79
83
  template = Template(self.template_plugins_str + template_str)
@@ -92,7 +96,7 @@ class Titles:
92
96
  Returns:
93
97
  str: The template string for the current action, or an empty string if not found.
94
98
  """
95
- action = context.get("view_action", "list")
99
+ action = context.get("view_action", "")
96
100
 
97
101
  template_str = self.titles.get(action)
98
102
  if template_str:
@@ -102,7 +106,7 @@ class Titles:
102
106
  if detail:
103
107
  return self.titles.get("detail", "")
104
108
 
105
- return ""
109
+ return self.titles.get("*", "")
106
110
 
107
111
  @property
108
112
  def template_plugins_str(self) -> str:
@@ -15,6 +15,7 @@ from django.contrib import messages
15
15
  from django.contrib.auth.decorators import permission_required
16
16
  from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin, UserPassesTestMixin
17
17
  from django.contrib.contenttypes.models import ContentType
18
+ from django.core.cache import cache
18
19
  from django.http import HttpResponseForbidden, HttpResponseServerError, JsonResponse
19
20
  from django.shortcuts import get_object_or_404, render
20
21
  from django.template import loader, RequestContext, Template
@@ -51,6 +52,11 @@ from nautobot.core.releases import get_latest_release
51
52
  from nautobot.core.utils.config import get_settings_or_config
52
53
  from nautobot.core.utils.lookup import get_route_for_model
53
54
  from nautobot.core.utils.permissions import get_permission_for_model
55
+ from nautobot.core.views.utils import (
56
+ generate_latest_with_cache,
57
+ is_metrics_experimental_caching_enabled,
58
+ METRICS_CACHE_KEY,
59
+ )
54
60
  from nautobot.extras.forms import GraphQLQueryForm
55
61
  from nautobot.extras.models import FileProxy, GraphQLQuery, Status
56
62
  from nautobot.extras.registry import registry
@@ -435,8 +441,17 @@ class NautobotAppMetricsCollector(Collector):
435
441
  def collect(self):
436
442
  """Collect metrics from plugins."""
437
443
  start = time.time()
438
- for metric_generator in registry["app_metrics"]:
439
- yield from metric_generator()
444
+ cached_lines = cache.get(METRICS_CACHE_KEY)
445
+ if not is_metrics_experimental_caching_enabled() or not cached_lines:
446
+ # If caching is disabled or no cache is found, generate metrics
447
+ for metric_generator in registry["app_metrics"]:
448
+ yield from metric_generator()
449
+ else:
450
+ # We stash the cached lines on the instance of the collector so that we can
451
+ # avoid a potential race condition where the cache expires between
452
+ # the time we check for it and the time we go to use it
453
+ # in generate_latest_with_cache()
454
+ self.local_cache = cached_lines
440
455
  gauge = GaugeMetricFamily("nautobot_app_metrics_processing_ms", "Time in ms to generate the app metrics")
441
456
  duration = time.time() - start
442
457
  gauge.add_metric([], format(duration * 1000, ".5f"))
@@ -485,7 +500,13 @@ class NautobotMetricsView(APIView):
485
500
  except ValueError:
486
501
  # Collector already registered, we are running without multiprocessing
487
502
  pass
488
- metrics_page = generate_latest(prometheus_registry)
503
+
504
+ if is_metrics_experimental_caching_enabled():
505
+ # Use the vendored version of generate_latest with Caching support
506
+ metrics_page = generate_latest_with_cache(prometheus_registry)
507
+ else:
508
+ # Use the original version of generate_latest to generate the metrics
509
+ metrics_page = generate_latest(prometheus_registry)
489
510
  return Response(metrics_page, content_type=CONTENT_TYPE_LATEST)
490
511
 
491
512