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
nautobot/apps/views.py CHANGED
@@ -30,6 +30,7 @@ from nautobot.core.views.mixins import (
30
30
  ObjectListViewMixin,
31
31
  ObjectNotesViewMixin,
32
32
  ObjectPermissionRequiredMixin,
33
+ UIComponentsMixin,
33
34
  )
34
35
  from nautobot.core.views.paginator import EnhancedPage, EnhancedPaginator, get_paginate_count
35
36
  from nautobot.core.views.renderers import NautobotHTMLRenderer
@@ -78,6 +79,7 @@ __all__ = (
78
79
  "ObjectNotesViewMixin",
79
80
  "ObjectPermissionRequiredMixin",
80
81
  "ObjectView",
82
+ "UIComponentsMixin",
81
83
  "check_and_call_git_repository_function",
82
84
  "check_filter_for_display",
83
85
  "csv_format",
@@ -1,9 +1,2 @@
1
1
  {% extends 'generic/object_retrieve.html' %}
2
- {% load helpers %}
3
-
4
- {% block breadcrumbs %}
5
- <li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
6
- <li><a href="{% url 'circuits:circuit_list' %}?provider={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
7
- <li>{{ object.circuit|hyperlinked_object }}</li>
8
- <li>{{ object|hyperlinked_object }}</li>
9
- {% endblock breadcrumbs %}
2
+ {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -0,0 +1,9 @@
1
+ {% spaceless %}
2
+ {% load helpers %}
3
+ {% if termination.port_speed and termination.upstream_speed %}
4
+ <i class="mdi mdi-arrow-down-bold" title="Downstream"></i>{{ termination.port_speed|humanize_speed }}&nbsp;
5
+ <i class="mdi mdi-arrow-up-bold" title="Upstream"></i>{{ termination.upstream_speed|humanize_speed }}
6
+ {% else %}
7
+ {{ termination.port_speed|humanize_speed|placeholder }}
8
+ {% endif %}
9
+ {% endspaceless %}
@@ -128,8 +128,8 @@ class CircuitTestCase(SeleniumTestCase, ObjectsListMixin, ObjectDetailsMixin):
128
128
  # Assert that value are properly set
129
129
  panel_label = f"Termination - {side} Side"
130
130
  self.assertPanelValue(panel_label, "Location", self.location.name)
131
- self.assertPanelValue(panel_label, "Port Speed (Kbps)", port_speed)
132
- self.assertPanelValue(panel_label, "Upstream Speed (Kbps)", upstream_speed)
131
+ self.assertPanelValue(panel_label, "Speed", port_speed)
132
+ self.assertPanelValue(panel_label, "Speed", upstream_speed)
133
133
  self.assertPanelValue(panel_label, "Cross-connect ID", xconnect_id)
134
134
  self.assertPanelValue(panel_label, "Patch Panel/port(s)", pp_info)
135
135
  self.assertPanelValue(panel_label, "Description", description)
@@ -5,6 +5,7 @@ from django.utils.html import format_html, format_html_join
5
5
 
6
6
  from nautobot.core.forms import ConfirmationForm
7
7
  from nautobot.core.templatetags import helpers
8
+ from nautobot.core.ui.breadcrumbs import Breadcrumbs, InstanceBreadcrumbItem, ModelBreadcrumbItem
8
9
  from nautobot.core.ui.choices import SectionChoices
9
10
  from nautobot.core.ui.object_detail import (
10
11
  ObjectDetailContent,
@@ -58,6 +59,10 @@ class CircuitTerminationObjectFieldsPanel(ObjectFieldsPanel):
58
59
  def render_key(self, key, value, context):
59
60
  if key == "connected_endpoint":
60
61
  return "IP Addressing"
62
+
63
+ if key == "port_speed":
64
+ return "Speed"
65
+
61
66
  return super().render_key(key, value, context)
62
67
 
63
68
  def render_value(self, key, value, context):
@@ -80,6 +85,10 @@ class CircuitTerminationObjectFieldsPanel(ObjectFieldsPanel):
80
85
  "{} ({})",
81
86
  ((helpers.hyperlinked_object(ip), getattr(ip, "vrf", None) or "Global") for ip in ip_addresses.all()),
82
87
  )
88
+
89
+ if key == "port_speed":
90
+ return render_component_template("circuits/inc/circuit_termination_speed_fragment.html", context)
91
+
83
92
  return super().render_value(key, value, context)
84
93
 
85
94
 
@@ -93,6 +102,20 @@ class CircuitTerminationUIViewSet(NautobotUIViewSet):
93
102
  serializer_class = serializers.CircuitTerminationSerializer
94
103
  table_class = tables.CircuitTerminationTable
95
104
 
105
+ breadcrumbs = Breadcrumbs(
106
+ items={
107
+ "detail": [
108
+ ModelBreadcrumbItem(model=Circuit),
109
+ ModelBreadcrumbItem(
110
+ model=Circuit,
111
+ label=lambda c: c["object"].circuit.provider,
112
+ reverse_query_params=lambda c: {"provider": c["object"].circuit.provider.pk},
113
+ ),
114
+ InstanceBreadcrumbItem(instance=lambda c: c["object"].circuit),
115
+ ]
116
+ }
117
+ )
118
+
96
119
  object_detail_content = ObjectDetailContent(
97
120
  panels=(
98
121
  CircuitTerminationObjectFieldsPanel(
@@ -104,7 +127,6 @@ class CircuitTerminationUIViewSet(NautobotUIViewSet):
104
127
  "cloud_network",
105
128
  "cable",
106
129
  "port_speed",
107
- "upstream_speed",
108
130
  "connected_endpoint",
109
131
  "xconnect_id",
110
132
  "pp_info",
@@ -115,15 +137,7 @@ class CircuitTerminationUIViewSet(NautobotUIViewSet):
115
137
  "provider_network",
116
138
  "cloud_network",
117
139
  "port_speed",
118
- "upstream_speed",
119
- ],
120
- exclude_fields=[
121
- "circuit",
122
140
  ],
123
- value_transforms={
124
- "port_speed": [helpers.humanize_speed],
125
- "upstream_speed": [helpers.humanize_speed],
126
- },
127
141
  ),
128
142
  )
129
143
  )
@@ -188,17 +202,12 @@ class CircuitUIViewSet(NautobotUIViewSet):
188
202
  "provider_network",
189
203
  "cloud_network",
190
204
  "port_speed",
191
- "upstream_speed",
192
205
  "ip_addresses",
193
206
  "xconnect_id",
194
207
  "pp_info",
195
208
  "description",
196
209
  ),
197
- value_transforms={
198
- "port_speed": [helpers.humanize_speed, helpers.placeholder],
199
- "upstream_speed": [helpers.humanize_speed],
200
- },
201
- hide_if_unset=("location", "provider_network", "cloud_network", "upstream_speed"),
210
+ hide_if_unset=("location", "provider_network", "cloud_network"),
202
211
  ignore_nonexistent_fields=True, # ip_addresses may be undefined
203
212
  header_extra_content_template_path="circuits/inc/circuit_termination_header_extra_content.html",
204
213
  **kwargs,
@@ -245,6 +254,10 @@ class CircuitUIViewSet(NautobotUIViewSet):
245
254
  )
246
255
  if key == "ip_addresses":
247
256
  return "IP Addressing"
257
+
258
+ if key == "port_speed":
259
+ return "Speed"
260
+
248
261
  return super().render_key(key, value, context)
249
262
 
250
263
  def render_value(self, key, value, context):
@@ -257,6 +270,10 @@ class CircuitUIViewSet(NautobotUIViewSet):
257
270
  if not context["termination"].location:
258
271
  return ""
259
272
  return render_component_template("circuits/inc/circuit_termination_cable_fragment.html", context)
273
+
274
+ if key == "port_speed":
275
+ return render_component_template("circuits/inc/circuit_termination_speed_fragment.html", context)
276
+
260
277
  return super().render_value(key, value, context)
261
278
 
262
279
  def queryset_list_url_filter(self, key, value, context):
nautobot/core/filters.py CHANGED
@@ -175,7 +175,7 @@ class RelatedMembershipBooleanFilter(django_filters.BooleanFilter):
175
175
  )
176
176
 
177
177
  def filter(self, qs, value):
178
- if value in EMPTY_VALUES:
178
+ if value in EMPTY_VALUES or (hasattr(value, "exists") and not value.exists()):
179
179
  return qs
180
180
  if self.distinct:
181
181
  qs = qs.distinct()
@@ -542,7 +542,7 @@ class TreeNodeMultipleChoiceFilter(NaturalKeyOrPKMultipleChoiceFilter):
542
542
  return query
543
543
 
544
544
  def filter(self, qs, value):
545
- if value in EMPTY_VALUES:
545
+ if value in EMPTY_VALUES or (hasattr(value, "exists") and not value.exists()):
546
546
  return qs
547
547
 
548
548
  # Fetch the generated Q object and filter the incoming qs with it before passing it along.
nautobot/core/settings.py CHANGED
@@ -157,6 +157,7 @@ METRICS_AUTHENTICATED = is_truthy(os.getenv("NAUTOBOT_METRICS_AUTHENTICATED", "F
157
157
  METRICS_DISABLED_APPS = []
158
158
  if "NAUTOBOT_METRICS_DISABLED_APPS" in os.environ and os.environ["NAUTOBOT_METRICS_DISABLED_APPS"] != "":
159
159
  METRICS_DISABLED_APPS = os.getenv("NAUTOBOT_METRICS_DISABLED_APPS", "").split(_CONFIG_SETTING_SEPARATOR)
160
+ METRICS_EXPERIMENTAL_CACHING_DURATION = int(os.getenv("NAUTOBOT_METRICS_EXPERIMENTAL_CACHING_DURATION", "0"))
160
161
 
161
162
  # Napalm
162
163
  NAPALM_ARGS = {}
@@ -1377,6 +1377,15 @@ properties:
1377
1377
  see_also:
1378
1378
  "Guide to Nautobot Prometheus metrics": "../guides/prometheus-metrics.md"
1379
1379
  type: "boolean"
1380
+ METRICS_EXPERIMENTAL_CACHING_DURATION:
1381
+ default: 0
1382
+ description: >-
1383
+ The number of seconds to cache Nautobot App metrics before refreshing them.
1384
+ A value of `0` means no caching (i.e. always recompute metrics on each request). This feature is experimental
1385
+ and may change or be removed in a future release, including patch releases.
1386
+ environment_variable: "NAUTOBOT_METRICS_EXPERIMENTAL_CACHING_DURATION"
1387
+ type: "integer"
1388
+ version_added: "2.4.18"
1380
1389
  NAPALM_ARGS:
1381
1390
  additionalProperties: true
1382
1391
  default: {}
nautobot/core/tables.py CHANGED
@@ -17,7 +17,6 @@ import django_tables2
17
17
  from django_tables2.data import TableData, TableQuerysetData
18
18
  from django_tables2.rows import BoundRows
19
19
  from django_tables2.utils import Accessor, OrderBy, OrderByTuple
20
- from tree_queries.models import TreeNode
21
20
 
22
21
  from nautobot.core.models.querysets import count_related
23
22
  from nautobot.core.templatetags import helpers
@@ -107,6 +106,12 @@ class BaseTable(django_tables2.Table):
107
106
  if order_by is None and saved_view is not None:
108
107
  order_by = saved_view.config.get("sort_order", None)
109
108
 
109
+ # Don't show hierarchy if we're sorted
110
+ if order_by is not None and hide_hierarchy_ui is None:
111
+ hide_hierarchy_ui = True
112
+
113
+ self.hide_hierarchy_ui = hide_hierarchy_ui
114
+
110
115
  # Init table
111
116
  super().__init__(*args, order_by=order_by, **kwargs)
112
117
 
@@ -118,12 +123,6 @@ class BaseTable(django_tables2.Table):
118
123
  *[column.name for column in self.columns if isinstance(column.column, LinkedCountColumn)],
119
124
  ]
120
125
 
121
- # Don't show hierarchy if we're sorted
122
- if order_by is not None and hide_hierarchy_ui is None:
123
- hide_hierarchy_ui = True
124
-
125
- self.hide_hierarchy_ui = hide_hierarchy_ui
126
-
127
126
  # Set default empty_text if none was provided
128
127
  if self.empty_text is None:
129
128
  self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found"
@@ -304,6 +303,10 @@ class BaseTable(django_tables2.Table):
304
303
  Arguments:
305
304
  value: iterable or comma separated string of order by aliases.
306
305
  """
306
+ # The below block of code is copied from Table.order_by()
307
+ # due to limitations in directly calling parent class methods within a property setter.
308
+ # See Python bug report: https://bugs.python.org/issue14965
309
+
307
310
  # collapse empty values to ()
308
311
  order_by = () if not value else value
309
312
  # accept string
@@ -316,22 +319,17 @@ class BaseTable(django_tables2.Table):
316
319
  valid.append(alias)
317
320
  self._order_by = OrderByTuple(valid)
318
321
 
319
- # The above block of code is copied from super().order_by
320
- # due to limitations in directly calling parent class methods within a property setter.
321
- # See Python bug report: https://bugs.python.org/issue14965
322
- model = getattr(self.Meta, "model", None)
323
- if model and issubclass(model, TreeNode):
324
- # Use the TreeNode model's approach to sorting
325
- queryset = self.data.data
326
- # If the data passed into the Table is a list (as in cases like BulkImport post),
327
- # convert this list to a queryset.
328
- # This ensures consistent behavior regardless of the input type.
329
- if isinstance(self.data.data, list):
330
- queryset = model.objects.filter(pk__in=[instance.pk for instance in self.data.data])
331
- self.data.data = queryset.extra(order_by=self._order_by)
332
- else:
333
- # Otherwise, use the default sorting method
334
- self.data.order_by(self._order_by)
322
+ # Nautobot-specific logic begins here
323
+ if self._order_by:
324
+ self.hide_hierarchy_ui = True
325
+ if isinstance(self.data.data, QuerySet) and hasattr(self.data.data, "without_tree_fields"):
326
+ self.data.data = self.data.data.without_tree_fields()
327
+ elif not self.hide_hierarchy_ui:
328
+ if isinstance(self.data.data, QuerySet) and hasattr(self.data.data, "with_tree_fields"):
329
+ self.data.data = self.data.data.with_tree_fields()
330
+
331
+ # Resume base class implementation
332
+ self.data.order_by(self._order_by)
335
333
 
336
334
  def add_conditional_prefetch(self, table_field, db_column=None, prefetch=None):
337
335
  """Conditionally prefetch the specified database column if the related table field is visible.
@@ -0,0 +1,19 @@
1
+ {% load helpers %}
2
+
3
+ <ol class="breadcrumb">
4
+ {% block breadcrumbs %}
5
+ {% if legacy_breadcrumbs %}
6
+ {{ legacy_breadcrumbs | safe }}
7
+ {% else %}
8
+ {% for url, title in breadcrumbs_items %}
9
+ <li>
10
+ {% if url %}
11
+ <a href="{{ url }}">{{ title }}</a>
12
+ {% else %}
13
+ {{ title }}
14
+ {% endif %}
15
+ </li>
16
+ {% endfor %}
17
+ {% endif %}
18
+ {% endblock %}
19
+ </ol>
@@ -1,7 +1,5 @@
1
1
  {% extends base_template %}
2
2
 
3
- {% block title %}{{ block.super }} - Change Log{% endblock %}
4
-
5
3
  {% block content %}
6
4
  {% include 'panel_table.html' %}
7
5
  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
@@ -11,19 +11,22 @@
11
11
  {% block header %}
12
12
  <div class="row noprint">
13
13
  <div class="{% if search_form %}col-sm-8 col-md-9 {% else %} col-md-12 {% endif %}">
14
- {% if breadcrumbs is not None %}
15
- {% render_breadcrumbs %}
16
- {% else %}
17
- {# TODO: remove this fragment after breadcrumbs implementation in generic views like ConsoleConnectionsListView #}
18
- <ol class="breadcrumb">
19
- {% block breadcrumbs %}
20
- {% if list_url %}
21
- <li><a href="{% url list_url %}">{{ title }}</a></li>
22
- {% endif %}
23
- {% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %}
24
- {% endblock breadcrumbs %}
25
- </ol>
14
+ {% captureas default_breadcrumbs %}
15
+ {% if list_url %}
16
+ <li><a href="{% url list_url %}">{{ title }}</a></li>
26
17
  {% endif %}
18
+ {% endcaptureas %}
19
+
20
+ {% captureas block_breadcrumbs %}
21
+ {% block breadcrumbs %}
22
+ {% if list_url %}
23
+ <li><a href="{% url list_url %}">{{ title }}</a></li>
24
+ {% endif %}
25
+ {% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %}
26
+ {% endblock breadcrumbs %}
27
+ {% endcaptureas %}
28
+
29
+ {% render_breadcrumbs default_breadcrumbs block_breadcrumbs %}
27
30
  </div>
28
31
  {% if search_form %}
29
32
  <div class="col-sm-4 col-md-3">
@@ -2,8 +2,6 @@
2
2
  {% load helpers %}
3
3
  {% load form_helpers %}
4
4
 
5
- {% block title %}{{ block.super }} - Notes{% endblock %}
6
-
7
5
  {% block content %}
8
6
  {% if perms.extras.add_note %}
9
7
  <form action="{% url 'extras:note_add' %}?return_url={{ request.path }}?tab=notes" method="post" enctype="multipart/form-data" class="form form-horizontal">
@@ -14,17 +14,24 @@
14
14
  <div class="row noprint">
15
15
  {% with list_url=object|validated_viewname:"list" %}
16
16
  <div class="col-sm-8 col-md-9">
17
- <ol class="breadcrumb">
17
+ {% captureas default_breadcrumbs %}
18
+ {% with list_url=object|validated_viewname:"list" %}
19
+ <li><a href="{% url list_url %}">{{ verbose_name_plural|bettertitle }}</a></li>
20
+ {% endwith %}
21
+ <li>{{ object|hyperlinked_object }}</li>
22
+ {% endcaptureas %}
23
+
24
+ {% captureas block_breadcrumbs %}
18
25
  {% block breadcrumbs %}
19
- {% if list_url %}
20
- <li><a href="{% url list_url %}">
21
- {{ verbose_name_plural|bettertitle }}
22
- </a></li>
23
- {% endif %}
26
+ {% with list_url=object|validated_viewname:"list" %}
27
+ <li><a href="{% url list_url %}">{{ verbose_name_plural|bettertitle }}</a></li>
28
+ {% endwith %}
24
29
  {% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %}
25
30
  <li>{{ object|hyperlinked_object }}</li>
26
- {% endblock breadcrumbs %}
27
- </ol>
31
+ {% endblock %}
32
+ {% endcaptureas %}
33
+
34
+ {% render_breadcrumbs default_breadcrumbs block_breadcrumbs %}
28
35
  </div>
29
36
  {% if list_url %}
30
37
  <div class="col-sm-4 col-md-3">
@@ -55,7 +62,7 @@
55
62
  {% block masthead %}
56
63
  <h1>
57
64
  <span class="hover_copy">
58
- <span id="copy_title">{% block title %}{{object.display|default:object}}{% endblock %}</span>
65
+ <span id="copy_title">{% block title %}{% render_title %}{% endblock %}</span>
59
66
  <button type="button" class="btn btn-inline btn-default hover_copy_button" data-clipboard-text="{{ object.display | default:object }}">
60
67
  <span class="mdi mdi-content-copy"></span>
61
68
  </button>
@@ -1352,3 +1352,27 @@ def saved_view_title(context, mode: Literal["html", "plain"] = "html"):
1352
1352
  return strip_tags(title)
1353
1353
 
1354
1354
  return title
1355
+
1356
+
1357
+ # https://www.djangosnippets.org/snippets/545/
1358
+ @register.tag(name="captureas")
1359
+ def do_captureas(parser, token):
1360
+ try:
1361
+ _, args = token.contents.split(None, 1)
1362
+ except ValueError:
1363
+ raise template.TemplateSyntaxError("'captureas' node requires a variable name.")
1364
+ nodelist = parser.parse(("endcaptureas",))
1365
+ parser.delete_first_token()
1366
+ return CaptureasNode(nodelist, args)
1367
+
1368
+
1369
+ class CaptureasNode(template.Node):
1370
+ def __init__(self, nodelist, varname):
1371
+ self.nodelist = nodelist
1372
+ self.varname = varname
1373
+
1374
+ def render(self, context):
1375
+ output = self.nodelist.render(context)
1376
+ output = output.strip()
1377
+ context[self.varname] = output
1378
+ return ""
@@ -1,10 +1,12 @@
1
+ from functools import partial
1
2
  import logging
2
3
 
3
4
  from django import template
4
- from django.utils.html import format_html_join
5
+ from django.utils.html import format_html_join, strip_spaces_between_tags
5
6
 
6
7
  from nautobot.core.ui.breadcrumbs import Breadcrumbs
7
8
  from nautobot.core.ui.titles import Titles
9
+ from nautobot.core.ui.utils import render_component_template
8
10
  from nautobot.core.utils.lookup import get_view_for_model
9
11
  from nautobot.core.views.utils import get_obj_from_context
10
12
 
@@ -31,21 +33,54 @@ def render_components(context, components):
31
33
 
32
34
  @register.simple_tag(takes_context=True)
33
35
  def render_title(context, mode="plain"):
36
+ """
37
+ Render the title passed in the context. Due to backwards compatibility in most of the Generic views,
38
+ we're either passing `title` to the template or render `title` defined in `view_titles`.
39
+
40
+ But in some newer views we want to have simple way to render title, only by defining `view_titles` within a view class.
41
+ """
42
+ if title := context.get("title"):
43
+ return title
44
+
34
45
  title_obj = context.get("view_titles")
35
46
  if title_obj is not None and isinstance(title_obj, Titles):
36
47
  return title_obj.render(context, mode=mode)
37
48
 
38
- if fallback_title := context.get("title"):
39
- return fallback_title
40
49
  return ""
41
50
 
42
51
 
43
52
  @register.simple_tag(takes_context=True)
44
- def render_breadcrumbs(context):
53
+ def render_breadcrumbs(context, legacy_default_breadcrumbs=None, legacy_block_breadcrumbs=None):
54
+ """
55
+ Renders the breadcrumbs using the UI Component Framework or legacy template-defined breadcrumbs.
56
+
57
+ Function checks if breadcrumbs from UI Component Framework are available and render them but only
58
+ when there is no other changes coming from legacy template-defined breadcrumbs.
59
+
60
+ Examples:
61
+ - UI Component Framework breadcrumbs are defined in the view. But in the template, {% block breadcrumbs %} is being used,
62
+ to override breadcrumbs or `{% block extra_breadcrumbs %}`. Output: template breadcrumbs will be rendered.
63
+ - There is no UI Component Framework breadcrumbs and no other block overrides. Output: default breadcrumbs will be rendered.
64
+ - UI Component Framework breadcrumbs are defined in the view. No breadcrumbs block overrides. Output: UI Component Framework breadcrumbs will be rendered.
65
+ """
66
+ render_template = partial(
67
+ render_component_template,
68
+ "components/breadcrumbs.html",
69
+ context,
70
+ )
71
+
72
+ if (
73
+ legacy_block_breadcrumbs
74
+ and strip_spaces_between_tags(legacy_default_breadcrumbs).strip()
75
+ != strip_spaces_between_tags(legacy_block_breadcrumbs).strip()
76
+ ):
77
+ return render_template(legacy_breadcrumbs=legacy_block_breadcrumbs)
78
+
45
79
  breadcrumbs_obj = context.get("breadcrumbs")
46
80
  if breadcrumbs_obj is not None and isinstance(breadcrumbs_obj, Breadcrumbs):
47
81
  return breadcrumbs_obj.render(context)
48
- return ""
82
+
83
+ return render_template(legacy_breadcrumbs=legacy_default_breadcrumbs)
49
84
 
50
85
 
51
86
  @register.simple_tag(takes_context=True)
@@ -99,6 +99,17 @@ class FilterTestCases:
99
99
  self.assertIsNotNone(self.filterset)
100
100
  return self.filterset.declared_filters["q"].filter_predicates
101
101
 
102
+ def test_no_distinct_on_empty_filter_params(self):
103
+ """Verify that an empty filterset doesn't cause a `SELECT DISTINCT`."""
104
+ self.assertIsNotNone(self.filterset)
105
+ filterset = self.filterset({}, self.queryset) # pylint: disable=not-callable # see assertion above
106
+ self.assertTrue(filterset.is_valid())
107
+ self.assertNotIn(
108
+ "SELECT DISTINCT",
109
+ str(filterset.qs.query),
110
+ "Filter set with empty parameter added `DISTINCT` to select query. This needs to be avoided because it incurs heavy performance penalties.",
111
+ )
112
+
102
113
  def test_id(self):
103
114
  """Verify that the filterset supports filtering by id with only lookup `__n`."""
104
115
  self.assertIsNotNone(self.filterset)
@@ -326,29 +337,34 @@ class FilterTestCases:
326
337
  if not isinstance(obj_field, (CharField, TextField)):
327
338
  self.skipTest("Not a CharField or TextField")
328
339
 
340
+ original_value = getattr(obj, obj_field_name)
329
341
  # Create random lowercase string to use for icontains lookup
330
342
  max_length = obj_field.max_length or CHARFIELD_MAX_LENGTH
331
343
  randomized_attr_value = "".join(random.choices(string.ascii_lowercase, k=max_length)) # noqa: S311 # pseudo-random generator
332
- setattr(obj, obj_field_name, randomized_attr_value)
333
- obj.save()
334
-
335
- # if lookup_method is iexact use the full updated attr
336
- if lookup_method == "iexact":
337
- lookup = randomized_attr_value.upper()
338
- model_queryset = self.queryset.filter(**{f"{filter_field_name}__iexact": lookup})
339
- else:
340
- lookup = randomized_attr_value[1:].upper()
341
- model_queryset = self.queryset.filter(**{f"{filter_field_name}__icontains": lookup})
342
- params = {"q": lookup}
343
- filterset_result = self.filterset(params, self.queryset) # pylint: disable=not-callable
344
-
345
- self.assertTrue(filterset_result.is_valid())
346
- self.assertQuerysetEqualAndNotEmpty(
347
- filterset_result.qs,
348
- model_queryset,
349
- ordered=False,
350
- msg=lookup,
351
- )
344
+ try:
345
+ setattr(obj, obj_field_name, randomized_attr_value)
346
+ obj.save()
347
+
348
+ # if lookup_method is iexact use the full updated attr
349
+ if lookup_method == "iexact":
350
+ lookup = randomized_attr_value.upper()
351
+ model_queryset = self.queryset.filter(**{f"{filter_field_name}__iexact": lookup})
352
+ else:
353
+ lookup = randomized_attr_value[1:].upper()
354
+ model_queryset = self.queryset.filter(**{f"{filter_field_name}__icontains": lookup})
355
+ params = {"q": lookup}
356
+ filterset_result = self.filterset(params, self.queryset) # pylint: disable=not-callable
357
+
358
+ self.assertTrue(filterset_result.is_valid())
359
+ self.assertQuerysetEqualAndNotEmpty(
360
+ filterset_result.qs,
361
+ model_queryset,
362
+ ordered=False,
363
+ msg=lookup,
364
+ )
365
+ finally:
366
+ setattr(obj, obj_field_name, original_value)
367
+ obj.save()
352
368
 
353
369
  def _get_relevant_filterset_queryset(self, queryset, *filter_params):
354
370
  """Gets the relevant queryset based on filter parameters."""
@@ -454,7 +470,7 @@ class FilterTestCases:
454
470
  tenant_groups = list(
455
471
  models.TenantGroup.objects.filter(
456
472
  tenants__isnull=False, **{f"tenants__{self.tenancy_related_name}__isnull": False}
457
- )
473
+ ).distinct()
458
474
  )[:2]
459
475
  tenant_groups_including_children = []
460
476
  for tenant_group in tenant_groups:
@@ -1,4 +1,5 @@
1
1
  import contextlib
2
+ import inspect
2
3
  import re
3
4
  from typing import Optional, Sequence
4
5
  from unittest import mock, skipIf
@@ -776,6 +777,30 @@ class ViewTestCases:
776
777
  response_body = response.content.decode(response.charset)
777
778
  self.assertNotIn('<i class="mdi mdi-circle-small"></i>', response_body)
778
779
 
780
+ def test_model_properties_as_table_columns_are_not_orderable(self):
781
+ """
782
+ Check for table columns that are property-based and not orderable.
783
+ """
784
+ table_class = getattr(self.get_list_view(), "table_class", None)
785
+ if not table_class:
786
+ return
787
+
788
+ queryset = self._get_queryset()
789
+ table = table_class(queryset)
790
+ model_cls = table._meta.model
791
+
792
+ property_fields = {name for name, _ in inspect.getmembers(model_cls, lambda o: isinstance(o, property))}
793
+
794
+ for name, column in table.base_columns.items():
795
+ if hasattr(column, "order_by") and column.order_by:
796
+ continue
797
+ if name in property_fields and name != "pk":
798
+ with self.subTest(column_name=name):
799
+ self.assertFalse(
800
+ column.orderable,
801
+ f"On Table `{table_class.__name__}` the property-based column `{name}` should be orderable=False or use a custom order_by",
802
+ )
803
+
779
804
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
780
805
  def test_list_objects_anonymous(self):
781
806
  # Make the request as an unauthenticated user