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.
- nautobot/apps/views.py +2 -0
- nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
- nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
- nautobot/circuits/tests/integration/test_circuit.py +2 -2
- nautobot/circuits/views.py +32 -15
- nautobot/core/filters.py +2 -2
- nautobot/core/settings.py +1 -0
- nautobot/core/settings.yaml +9 -0
- nautobot/core/tables.py +21 -23
- nautobot/core/templates/components/breadcrumbs.html +19 -0
- nautobot/core/templates/generic/object_changelog.html +0 -2
- nautobot/core/templates/generic/object_list.html +15 -12
- nautobot/core/templates/generic/object_notes.html +0 -2
- nautobot/core/templates/generic/object_retrieve.html +16 -9
- nautobot/core/templatetags/helpers.py +24 -0
- nautobot/core/templatetags/ui_framework.py +40 -5
- nautobot/core/testing/filters.py +37 -21
- nautobot/core/testing/views.py +25 -0
- nautobot/core/tests/test_tables.py +43 -6
- nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
- nautobot/core/tests/test_titles.py +2 -2
- nautobot/core/tests/test_ui.py +14 -1
- nautobot/core/tests/test_views.py +45 -0
- nautobot/core/ui/breadcrumbs.py +13 -8
- nautobot/core/ui/object_detail.py +43 -5
- nautobot/core/ui/titles.py +9 -5
- nautobot/core/views/__init__.py +24 -3
- nautobot/core/views/generic.py +42 -17
- nautobot/core/views/mixins.py +146 -12
- nautobot/core/views/utils.py +117 -0
- nautobot/dcim/models/devices.py +4 -0
- nautobot/dcim/tables/__init__.py +2 -0
- nautobot/dcim/tables/devices.py +24 -0
- nautobot/dcim/tables/power.py +2 -2
- nautobot/dcim/templates/dcim/device/base.html +1 -11
- nautobot/dcim/templates/dcim/device_component.html +0 -19
- nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
- nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
- nautobot/dcim/tests/test_views.py +41 -0
- nautobot/dcim/views.py +160 -39
- nautobot/extras/filters/mixins.py +1 -1
- nautobot/extras/forms/forms.py +15 -0
- nautobot/extras/models/groups.py +10 -1
- nautobot/extras/models/jobs.py +2 -2
- nautobot/extras/plugins/views.py +18 -5
- nautobot/extras/tables.py +4 -2
- nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
- nautobot/extras/templates/extras/dynamicgroup.html +2 -99
- nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
- nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
- nautobot/extras/templates/extras/gitrepository.html +2 -82
- nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
- nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
- nautobot/extras/templates/extras/gitrepository_update.html +13 -0
- nautobot/extras/templates/extras/note_retrieve.html +0 -52
- nautobot/extras/templates/extras/plugin_detail.html +3 -7
- nautobot/extras/templates/extras/plugins_list.html +0 -2
- nautobot/extras/tests/test_dynamicgroups.py +73 -18
- nautobot/extras/tests/test_views.py +5 -0
- nautobot/extras/urls.py +2 -94
- nautobot/extras/views.py +424 -430
- nautobot/ipam/querysets.py +3 -3
- nautobot/ipam/signals.py +6 -1
- nautobot/ipam/templates/ipam/prefix.html +0 -8
- nautobot/ipam/tests/test_api.py +5 -0
- nautobot/ipam/tests/test_models.py +387 -0
- nautobot/ipam/tests/test_querysets.py +46 -0
- nautobot/ipam/utils/migrations.py +1 -1
- nautobot/ipam/views.py +17 -8
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
- nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
- nautobot/project-static/docs/development/core/getting-started.html +0 -15
- nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +300 -300
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
- nautobot/project-static/img/nautobot_icon.svg +32 -34
- nautobot/project-static/js/table_sorting_indicator.js +0 -2
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
- nautobot/core/templates/inc/breadcrumbs.html +0 -14
- nautobot/project-static/docs/requirements.txt +0 -14
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
- {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(
|
|
22
|
-
sorted_queryset =
|
|
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
|
-
|
|
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
|
|
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:
|
nautobot/core/tests/test_ui.py
CHANGED
|
@@ -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):
|
nautobot/core/ui/breadcrumbs.py
CHANGED
|
@@ -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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 = "
|
|
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", "
|
|
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
|
|
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,
|
|
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)
|
|
1234
|
-
|
|
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 =
|
|
1287
|
-
|
|
1288
|
-
|
|
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:
|
nautobot/core/ui/titles.py
CHANGED
|
@@ -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", "
|
|
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:
|
nautobot/core/views/__init__.py
CHANGED
|
@@ -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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|