nautobot 2.4.20__py3-none-any.whl → 2.4.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. nautobot/circuits/templates/circuits/circuit.html +1 -1
  2. nautobot/circuits/templates/circuits/circuittermination.html +1 -1
  3. nautobot/circuits/templates/circuits/circuittype.html +1 -1
  4. nautobot/circuits/templates/circuits/providernetwork.html +1 -1
  5. nautobot/core/cli/migrate_deprecated_templates.py +200 -0
  6. nautobot/core/jobs/__init__.py +2 -1
  7. nautobot/core/jobs/groups.py +31 -1
  8. nautobot/core/models/tree_queries.py +10 -5
  9. nautobot/core/signals.py +12 -1
  10. nautobot/core/templates/components/panel/panel.html +1 -1
  11. nautobot/core/templates/inc/image_attachments.html +2 -1
  12. nautobot/core/templatetags/helpers.py +22 -0
  13. nautobot/core/tests/runner.py +3 -0
  14. nautobot/core/tests/test_cli.py +40 -0
  15. nautobot/core/tests/test_forms.py +41 -0
  16. nautobot/core/tests/test_jobs.py +75 -1
  17. nautobot/core/tests/test_tree_queries.py +14 -1
  18. nautobot/core/ui/object_detail.py +41 -5
  19. nautobot/core/utils/filtering.py +11 -9
  20. nautobot/core/views/generic.py +3 -3
  21. nautobot/dcim/models/device_components.py +81 -68
  22. nautobot/dcim/templates/dcim/device/config.html +1 -1
  23. nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
  24. nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
  25. nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
  26. nautobot/dcim/templates/dcim/device/frontports.html +1 -1
  27. nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
  28. nautobot/dcim/templates/dcim/device/inventory.html +1 -1
  29. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
  30. nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
  31. nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
  32. nautobot/dcim/templates/dcim/device/powerports.html +1 -1
  33. nautobot/dcim/templates/dcim/device/rearports.html +1 -1
  34. nautobot/dcim/templates/dcim/device/status.html +1 -1
  35. nautobot/dcim/templates/dcim/device/wireless.html +1 -1
  36. nautobot/dcim/templates/dcim/device.html +1 -1
  37. nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
  38. nautobot/dcim/templates/dcim/devicetype.html +1 -1
  39. nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
  40. nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
  41. nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
  42. nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
  43. nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
  44. nautobot/dcim/templates/dcim/powerfeed.html +1 -1
  45. nautobot/dcim/templates/dcim/powerpanel.html +1 -1
  46. nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
  47. nautobot/dcim/tests/test_models.py +43 -3
  48. nautobot/dcim/tests/test_views.py +52 -21
  49. nautobot/dcim/views.py +203 -87
  50. nautobot/extras/api/views.py +9 -1
  51. nautobot/extras/filters/customfields.py +9 -3
  52. nautobot/extras/models/groups.py +42 -5
  53. nautobot/extras/signals.py +20 -19
  54. nautobot/extras/tables.py +31 -2
  55. nautobot/extras/templates/extras/computedfield.html +1 -1
  56. nautobot/extras/templates/extras/configcontext.html +1 -1
  57. nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
  58. nautobot/extras/templates/extras/customfield.html +1 -1
  59. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
  60. nautobot/extras/templates/extras/gitrepository_result.html +0 -2
  61. nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
  62. nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
  63. nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
  64. nautobot/extras/templates/extras/secretsgroup.html +1 -1
  65. nautobot/extras/templates/extras/tag.html +1 -1
  66. nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
  67. nautobot/extras/tests/test_api.py +1 -0
  68. nautobot/extras/tests/test_changelog.py +28 -0
  69. nautobot/extras/tests/test_customfields.py +10 -2
  70. nautobot/extras/tests/test_dynamicgroups.py +37 -1
  71. nautobot/extras/views.py +49 -19
  72. nautobot/ipam/signals.py +71 -0
  73. nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
  74. nautobot/ipam/templates/ipam/service.html +1 -1
  75. nautobot/ipam/templates/ipam/vlan.html +1 -1
  76. nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
  77. nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
  78. nautobot/ipam/tests/test_models.py +42 -0
  79. nautobot/users/templates/users/sessionkey_delete.html +1 -1
  80. nautobot/users/views.py +2 -2
  81. nautobot/virtualization/models.py +1 -68
  82. nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
  83. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
  84. nautobot/virtualization/tests/test_models.py +42 -3
  85. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
  86. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
  87. nautobot-2.4.21.dist-info/entry_points.txt +4 -0
  88. nautobot-2.4.20.dist-info/entry_points.txt +0 -3
  89. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/WHEEL +0 -0
@@ -1,2 +1,2 @@
1
- {% extends 'circuits/circuit_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}2.0 TODO: remove this template, which only exists for backward compatibility with 1.3 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'circuits/circuittermination_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}2.0 TODO: remove this template, which only exists for backward compatibility with 1.3 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'circuits/circuittype_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'circuits/providernetwork_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -0,0 +1,200 @@
1
+ import argparse
2
+ import os
3
+ import re
4
+
5
+ TEMPLATE_REPLACEMENTS = {
6
+ # Format: new_template: [old_template1, old_template2, ...]
7
+ "circuits/circuit_create.html": ["circuits/circuit_edit.html"],
8
+ "circuits/circuittermination_create.html": ["circuits/circuittermination_edit.html"],
9
+ "circuits/provider_create.html": ["circuits/provider_edit.html"],
10
+ "circuits/provider_retrieve.html": ["circuits/provider.html"],
11
+ "dcim/cable_retrieve.html": ["dcim/cable.html"],
12
+ "dcim/cable_update.html": ["dcim/cable_edit.html"],
13
+ "dcim/device_create.html": ["dcim/device_edit.html"],
14
+ "dcim/devicetype_update.html": ["dcim/devicetype_edit.html"],
15
+ "dcim/location_retrieve.html": ["dcim/location.html"],
16
+ "dcim/location_update.html": ["dcim/location_edit.html"],
17
+ "dcim/rack_retrieve.html": ["dcim/rack.html"],
18
+ "dcim/rack_update.html": ["dcim/rack_edit.html"],
19
+ "dcim/rackreservation_retrieve.html": ["dcim/rackreservation.html"],
20
+ "dcim/virtualchassis_create.html": ["dcim/virtualchassis_add.html"],
21
+ "extras/configcontext_update.html": ["extras/configcontext_edit.html"],
22
+ "extras/configcontextschema_retrieve.html": ["extras/configcontextschema.html"],
23
+ "extras/configcontextschema_update.html": ["extras/configcontextschema_edit.html"],
24
+ "extras/customfield_update.html": ["extras/customfield_edit.html"],
25
+ "extras/dynamicgroup_retrieve.html": ["extras/dynamicgroup.html"],
26
+ "extras/dynamicgroup_update.html": ["extras/dynamicgroup_edit.html"],
27
+ "extras/gitrepository_retrieve.html": ["extras/gitrepository.html"],
28
+ "extras/gitrepository_update.html": ["extras/gitrepository_object_edit.html"],
29
+ "extras/graphqlquery_retrieve.html": ["extras/graphqlquery.html"],
30
+ "extras/jobresult_retrieve.html": ["extras/jobresult.html"],
31
+ "extras/note_retrieve.html": ["extras/note.html"],
32
+ "extras/objectchange_retrieve.html": ["extras/objectchange.html"],
33
+ "extras/secretsgroup_update.html": ["extras/secretsgroup_edit.html"],
34
+ "extras/tag_update.html": ["extras/tag_edit.html"],
35
+ "generic/object_bulk_create.html": ["generic/object_bulk_import.html"],
36
+ "generic/object_bulk_destroy.html": ["generic/object_bulk_delete.html"],
37
+ "generic/object_bulk_update.html": ["generic/object_bulk_edit.html"],
38
+ "generic/object_changelog.html": ["extras/object_changelog.html"],
39
+ "generic/object_create.html": ["dcim/powerpanel_edit.html", "generic/object_edit.html", "ipam/service_edit.html"],
40
+ "generic/object_destroy.html": ["generic/object_delete.html"],
41
+ "generic/object_notes.html": ["extras/object_notes.html"],
42
+ "generic/object_retrieve.html": [
43
+ "circuits/circuit.html",
44
+ "circuits/circuit_retrieve.html",
45
+ "circuits/circuittermination.html",
46
+ "circuits/circuittermination_retrieve.html",
47
+ "circuits/circuittype.html",
48
+ "circuits/circuittype_retrieve.html",
49
+ "circuits/providernetwork.html",
50
+ "circuits/providernetwork_retrieve.html",
51
+ "cloud/cloudaccount_retrieve.html",
52
+ "cloud/cloudnetwork_retrieve.html",
53
+ "cloud/cloudresourcetype_retrieve.html",
54
+ "cloud/cloudservice_retrieve.html",
55
+ "dcim/controller/base.html",
56
+ "dcim/controller_retrieve.html",
57
+ "dcim/controller_wirelessnetworks.html",
58
+ "dcim/controllermanageddevicegroup_retrieve.html",
59
+ "dcim/device/base.html",
60
+ "dcim/device/consoleports.html",
61
+ "dcim/device/consoleserverports.html",
62
+ "dcim/device/devicebays.html",
63
+ "dcim/device/frontports.html",
64
+ "dcim/device/interfaces.html",
65
+ "dcim/device/inventory.html",
66
+ "dcim/device/modulebays.html",
67
+ "dcim/device/poweroutlets.html",
68
+ "dcim/device/powerports.html",
69
+ "dcim/device/rearports.html",
70
+ "dcim/device/wireless.html",
71
+ "dcim/devicefamily_retrieve.html",
72
+ "dcim/deviceredundancygroup_retrieve.html",
73
+ "dcim/devicetype.html",
74
+ "dcim/devicetype_retrieve.html",
75
+ "dcim/interfaceredundancygroup_retrieve.html",
76
+ "dcim/locationtype.html",
77
+ "dcim/locationtype_retrieve.html",
78
+ "dcim/manufacturer.html",
79
+ "dcim/platform.html",
80
+ "dcim/powerfeed.html",
81
+ "dcim/powerfeed_retrieve.html",
82
+ "dcim/powerpanel.html",
83
+ "dcim/powerpanel_retrieve.html",
84
+ "dcim/rackgroup.html",
85
+ "dcim/softwareimagefile_retrieve.html",
86
+ "dcim/softwareversion_retrieve.html",
87
+ "dcim/virtualchassis.html",
88
+ "dcim/virtualchassis_retrieve.html",
89
+ "dcim/virtualdevicecontext_retrieve.html",
90
+ "extras/computedfield.html",
91
+ "extras/computedfield_retrieve.html",
92
+ "extras/configcontext.html",
93
+ "extras/configcontext_retrieve.html",
94
+ "extras/contact_retrieve.html",
95
+ "extras/customfield.html",
96
+ "extras/customfield_retrieve.html",
97
+ "extras/customlink.html",
98
+ "extras/exporttemplate.html",
99
+ "extras/job_detail.html",
100
+ "extras/jobbutton_retrieve.html",
101
+ "extras/jobhook.html",
102
+ "extras/jobqueue_retrieve.html",
103
+ "extras/metadatatype_retrieve.html",
104
+ "extras/secretsgroup.html",
105
+ "extras/secretsgroup_retrieve.html",
106
+ "extras/status.html",
107
+ "extras/tag.html",
108
+ "extras/tag_retrieve.html",
109
+ "extras/team_retrieve.html",
110
+ "generic/object_detail.html",
111
+ "ipam/rir.html",
112
+ "ipam/service.html",
113
+ "ipam/service_retrieve.html",
114
+ "ipam/vlan.html",
115
+ "ipam/vlan_retrieve.html",
116
+ "ipam/vlangroup.html",
117
+ "tenancy/tenant.html",
118
+ "virtualization/clustergroup.html",
119
+ "virtualization/clustertype.html",
120
+ "virtualization/virtualmachine.html",
121
+ "virtualization/virtualmachine_retrieve.html",
122
+ "wireless/radioprofile_retrieve.html",
123
+ "wireless/supporteddatarate_retrieve.html",
124
+ "wireless/wirelessnetwork_retrieve.html",
125
+ ],
126
+ "ipam/prefix_retrieve.html": ["ipam/prefix.html"],
127
+ "ipam/vlan_update.html": ["ipam/vlan_edit.html"],
128
+ "tenancy/tenant_create.html": ["tenancy/tenant_edit.html"],
129
+ "tenancy/tenantgroup_retrieve.html": ["tenancy/tenantgroup.html"],
130
+ "virtualchassis_update.html": ["dcim/virtualchassis_edit.html"],
131
+ "virtualization/virtualmachine_update.html": ["virtualization/virtualmachine_edit.html"],
132
+ }
133
+
134
+
135
+ def replace_template_references(content: str) -> tuple[str, bool]:
136
+ """
137
+ Replaces references to deprecated templates with new ones.
138
+
139
+ Args:
140
+ content: The content of the file to replace references in.
141
+
142
+ Returns:
143
+ A tuple containing the updated content and a boolean indicating if any changes were made.
144
+ """
145
+ for new_template, old_templates in TEMPLATE_REPLACEMENTS.items():
146
+ for old_template in old_templates:
147
+ pattern = rf"(\{{%\s*extends\s*['\"]){re.escape(old_template)}(['\"]\s*%\}})"
148
+ new_content, count = re.subn(pattern, rf"\1{new_template}\2", content)
149
+ if count > 0:
150
+ # A django template can only have one extends statement, so we can return as soon as we find a match.
151
+ return new_content, True
152
+
153
+ return content, False
154
+
155
+
156
+ def replace_deprecated_templates(path: str, dry_run: bool = False):
157
+ """
158
+ Recursively finds all .html files in the given directory,
159
+ and replaces references to deprecated templates with new ones.
160
+ """
161
+
162
+ if os.path.isfile(path):
163
+ only_filename = os.path.basename(path)
164
+ path = os.path.dirname(path)
165
+ else:
166
+ only_filename = None
167
+
168
+ for root, _, files in os.walk(path):
169
+ for filename in files:
170
+ if only_filename and only_filename != filename:
171
+ continue
172
+ if filename.endswith((".html")):
173
+ file_path = os.path.join(root, filename)
174
+ with open(file_path, "r", encoding="utf-8") as f:
175
+ original_content = f.read()
176
+
177
+ content = original_content
178
+
179
+ fixed_content, was_updated = replace_template_references(content)
180
+
181
+ if was_updated:
182
+ if dry_run:
183
+ print(f"Detected deprecated template reference in {file_path}")
184
+ continue
185
+ with open(file_path, "w", encoding="utf-8") as f:
186
+ f.write(fixed_content)
187
+ print(f"Updated: {file_path}")
188
+
189
+
190
+ def main():
191
+ parser = argparse.ArgumentParser(description="Replace deprecated templates with new ones")
192
+ parser.add_argument("path", type=str, help="Path to directory in which to recursively fix all .html files.")
193
+ parser.add_argument("--dry-run", action="store_true", help="Do not make any changes to the files.")
194
+ args = parser.parse_args()
195
+
196
+ replace_deprecated_templates(args.path, args.dry_run)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -19,7 +19,7 @@ from nautobot.core.celery import app, register_jobs
19
19
  from nautobot.core.exceptions import AbortTransaction
20
20
  from nautobot.core.jobs.bulk_actions import BulkDeleteObjects, BulkEditObjects
21
21
  from nautobot.core.jobs.cleanup import LogsCleanup
22
- from nautobot.core.jobs.groups import RefreshDynamicGroupCaches
22
+ from nautobot.core.jobs.groups import RefreshDynamicGroupCacheJobButtonReceiver, RefreshDynamicGroupCaches
23
23
  from nautobot.core.utils.lookup import get_filterset_for_model
24
24
  from nautobot.core.utils.requests import get_filterable_params_from_filter_params
25
25
  from nautobot.extras.datasources import (
@@ -381,5 +381,6 @@ jobs = [
381
381
  ImportObjects,
382
382
  LogsCleanup,
383
383
  RefreshDynamicGroupCaches,
384
+ RefreshDynamicGroupCacheJobButtonReceiver,
384
385
  ]
385
386
  register_jobs(*jobs)
@@ -1,5 +1,5 @@
1
1
  from nautobot.extras.choices import DynamicGroupTypeChoices
2
- from nautobot.extras.jobs import Job, ObjectVar
2
+ from nautobot.extras.jobs import Job, JobButtonReceiver, ObjectVar
3
3
  from nautobot.extras.models import DynamicGroup
4
4
 
5
5
  name = "System Jobs"
@@ -31,8 +31,38 @@ class RefreshDynamicGroupCaches(Job):
31
31
  if single_group is not None:
32
32
  groups = groups.filter(pk=single_group.pk)
33
33
 
34
+ if not groups.exists():
35
+ self.logger.info("No relevant dynamic groups were specified, nothing to do.")
36
+ return
37
+
38
+ self.logger.info("Re-calculating and re-caching group members. This may take some time.")
34
39
  for group in groups:
35
40
  group.update_cached_members()
36
41
  self.logger.info("Cache refreshed successfully, now with %d members", group.count, extra={"object": group})
37
42
 
38
43
  self.logger.info("Cache(s) refreshed")
44
+
45
+
46
+ class RefreshDynamicGroupCacheJobButtonReceiver(JobButtonReceiver):
47
+ """
48
+ System Job Button Receiver to re-calculate and re-cache the members of a given Dynamic Group.
49
+ """
50
+
51
+ class Meta:
52
+ name = "Refresh Dynamic Group Cache (Job Button Receiver)"
53
+ description = "Re-calculate and re-cache the membership list of a given Dynamic Group."
54
+
55
+ def receive_job_button(self, obj):
56
+ if not isinstance(obj, DynamicGroup):
57
+ self.fail("This job button should only be used with Dynamic Group records.")
58
+ elif obj.group_type == DynamicGroupTypeChoices.TYPE_STATIC:
59
+ self.fail(
60
+ "The members of this Dynamic Group are statically defined and do not need to be recalculated.",
61
+ extra={"object": obj},
62
+ )
63
+ else:
64
+ self.logger.info(
65
+ "Re-calculating and re-caching group members. This may take some time.", extra={"object": obj}
66
+ )
67
+ obj.update_cached_members()
68
+ self.logger.success("Cache refreshed successfully, now with %d members", obj.count, extra={"object": obj})
@@ -1,3 +1,5 @@
1
+ import uuid
2
+
1
3
  from django.core.cache import cache
2
4
  from django.db.models import Case, When
3
5
  from django.db.models.signals import post_delete, post_save
@@ -20,15 +22,18 @@ class TreeQuerySet(TreeQuerySet_, querysets.RestrictedQuerySet):
20
22
  Dynamically computes ancestors either through the tree or through the `parent` foreign key depending on whether
21
23
  tree fields are present on `of`.
22
24
  """
25
+
26
+ # If `of` is a UUID, i.e. pk, retrieve the corresponding model instance with tree fields disabled.
27
+ if isinstance(of, uuid.UUID):
28
+ of = self.model.objects.without_tree_fields().get(pk=of)
29
+
23
30
  # If `of` has `tree_depth` defined, i.e. if it was retrieved from the database on a queryset where tree fields
24
31
  # were enabled (see `TreeQuerySet.with_tree_fields` and `TreeQuerySet.without_tree_fields`), use the default
25
32
  # implementation from `tree_queries.query.TreeQuerySet`.
26
- # Furthermore, if `of` doesn't have a parent field we also have to defer to the tree-based implementation which
27
- # will then annotate the tree fields and proceed as usual.
28
- if hasattr(of, "tree_depth") or not hasattr(of, "parent"):
33
+ if hasattr(of, "tree_depth"):
29
34
  return super().ancestors(of, include_self=include_self)
35
+
30
36
  # In the other case, traverse the `parent` foreign key until the root.
31
- model_class = of._meta.concrete_model
32
37
  ancestor_pks = []
33
38
  if include_self:
34
39
  ancestor_pks.append(of.pk)
@@ -39,7 +44,7 @@ class TreeQuerySet(TreeQuerySet_, querysets.RestrictedQuerySet):
39
44
  # Reference:
40
45
  # https://stackoverflow.com/questions/4916851/django-get-a-queryset-from-array-of-ids-in-specific-order
41
46
  preserve_order = Case(*[When(pk=pk, then=position) for position, pk in enumerate(ancestor_pks)])
42
- return model_class.objects.without_tree_fields().filter(pk__in=ancestor_pks).order_by(preserve_order)
47
+ return self.model.objects.without_tree_fields().filter(pk__in=ancestor_pks).order_by(preserve_order)
43
48
 
44
49
  def max_tree_depth(self):
45
50
  r"""
nautobot/core/signals.py CHANGED
@@ -67,10 +67,21 @@ def invalidate_max_depth_cache(sender, **kwargs):
67
67
 
68
68
  Note that this signal is connected in `TreeModel.__init_subclass__()` so as to only apply to those models.
69
69
  """
70
- from nautobot.core.models.tree_queries import TreeManager
70
+ from nautobot.core.models.tree_queries import TreeManager, TreeNode
71
71
 
72
72
  if not isinstance(sender.objects, TreeManager):
73
73
  return
74
74
 
75
+ # If the instance is a TreeNode, and it has siblings, skip invalidating the cache.
76
+ instance = kwargs.get("instance", None)
77
+ if isinstance(instance, TreeNode):
78
+ try:
79
+ parent = getattr(instance, "parent", None)
80
+ except instance.DoesNotExist:
81
+ parent = None
82
+ if parent and getattr(parent, "children", None) and parent.children.count() > 1:
83
+ # TreeNode has siblings, depth can't change
84
+ return
85
+
75
86
  with contextlib.suppress(redis.exceptions.ConnectionError):
76
87
  cache.delete(sender.objects.max_depth_cache_key)
@@ -1,4 +1,4 @@
1
- <div class="panel panel-default">
1
+ <div class="panel panel-{{ css_class|default:"default"}}">
2
2
  {% if label.strip or header_extra_content.strip %}
3
3
  <div class="panel-heading">
4
4
  {% if label %}
@@ -1,3 +1,4 @@
1
+ {% comment %}3.0 TODO: Potentially remove this template pending no other references to it.{% endcomment %}
1
2
  {% if images %}
2
3
  <table class="table table-hover panel-body">
3
4
  <tr>
@@ -33,4 +34,4 @@
33
34
  <div class="panel-body">
34
35
  <span class="text-muted">None</span>
35
36
  </div>
36
- {% endif %}
37
+ {% endif %}
@@ -14,9 +14,11 @@ from django.contrib.staticfiles.finders import find
14
14
  from django.core.exceptions import ObjectDoesNotExist
15
15
  from django.templatetags.static import static, StaticNode
16
16
  from django.urls import NoReverseMatch, reverse
17
+ from django.utils.formats import date_format
17
18
  from django.utils.html import format_html, format_html_join, strip_tags
18
19
  from django.utils.safestring import mark_safe
19
20
  from django.utils.text import slugify as django_slugify
21
+ from django.utils.translation import gettext as _
20
22
  from django_jinja import library
21
23
  from markdown import markdown
22
24
  import yaml
@@ -831,6 +833,26 @@ def label_list(value, suffix=""):
831
833
  )
832
834
 
833
835
 
836
+ @library.filter()
837
+ @register.filter()
838
+ def format_timezone(time_zone):
839
+ """
840
+ Return a human-readable representation of a time zone including:
841
+ - Time zone name and UTC offset on the first line
842
+ - Local date and time on the next line (in smaller font)
843
+ """
844
+ if not time_zone:
845
+ return HTML_NONE
846
+
847
+ now = datetime.datetime.now(time_zone)
848
+
849
+ # Locale-aware formatting (respects USE_L10N + active locale)
850
+ local_time = date_format(now, format="DATETIME_FORMAT", use_l10n=True)
851
+
852
+ result = f"{time_zone} (UTC {now.strftime('%z')})<br><small>{_('Local time')}: {local_time}</small>"
853
+ return format_html(result)
854
+
855
+
834
856
  #
835
857
  # Tags
836
858
  #
@@ -157,6 +157,9 @@ class NautobotTestRunner(DiscoverRunner):
157
157
  db_command = [*command, "--database", alias]
158
158
  call_command(*db_command)
159
159
 
160
+ # Calculate membership for the dynamic groups that were generated by the factories/fixtures
161
+ call_command("refresh_dynamic_group_member_caches")
162
+
160
163
  if self.parallel > 1:
161
164
  for index in range(self.parallel):
162
165
  with time_keeper.timed(f" Cloning '{alias}'"):
@@ -0,0 +1,40 @@
1
+ from nautobot.core.cli import migrate_deprecated_templates
2
+ from nautobot.core.testing import TestCase
3
+
4
+
5
+ class TestMigrateTemplates(TestCase):
6
+ def test_template_replacements(self):
7
+ """Verify that all old templates are replaced by a single new template."""
8
+ audit_dict = {}
9
+ for new_template, old_templates in migrate_deprecated_templates.TEMPLATE_REPLACEMENTS.items():
10
+ for old_template in old_templates:
11
+ self.assertNotIn(old_template, audit_dict)
12
+ audit_dict[old_template] = new_template
13
+
14
+ def test_replace_template_references_no_change(self):
15
+ content = """
16
+ {% extends "base.html" %}
17
+ {% block content %}
18
+ <h1>Hello, World!</h1>
19
+ {% endblock %}
20
+ """
21
+ replaced_content, was_updated = migrate_deprecated_templates.replace_template_references(content)
22
+ self.assertFalse(was_updated)
23
+ self.assertEqual(replaced_content, content)
24
+
25
+ def test_replace_template_references(self):
26
+ original_content = """
27
+ {% extends "generic/object_bulk_import.html" %}
28
+ {% block content %}
29
+ <h1>Hello, World!</h1>
30
+ {% endblock %}
31
+ """
32
+ new_content = """
33
+ {% extends "generic/object_bulk_create.html" %}
34
+ {% block content %}
35
+ <h1>Hello, World!</h1>
36
+ {% endblock %}
37
+ """
38
+ replaced_content, was_updated = migrate_deprecated_templates.replace_template_references(original_content)
39
+ self.assertTrue(was_updated)
40
+ self.assertEqual(replaced_content, new_content)
@@ -1,14 +1,19 @@
1
+ import inspect
2
+ import sys
1
3
  from unittest import mock
2
4
 
3
5
  from django import forms as django_forms
6
+ from django.apps import apps as django_apps
4
7
  from django.contrib.contenttypes.models import ContentType
5
8
  from django.http import QueryDict
6
9
  from django.test import TestCase
7
10
  from django.urls import reverse
11
+ from django_filters.filterset import FilterSet
8
12
  from netaddr import IPNetwork
9
13
 
10
14
  from nautobot.core import filters, forms, testing
11
15
  from nautobot.core.utils import requests
16
+ from nautobot.core.utils.filtering import get_filterset_parameter_form_field
12
17
  from nautobot.dcim import filters as dcim_filters, forms as dcim_forms, models as dcim_models
13
18
  from nautobot.dcim.tests import test_views
14
19
  from nautobot.extras import filters as extras_filters, models as extras_models
@@ -624,6 +629,42 @@ class WidgetsTest(TestCase):
624
629
 
625
630
 
626
631
  class DynamicFilterFormTest(TestCase):
632
+ def test_get_filterset_parameter_form_field_all_filters(self):
633
+ """
634
+ Test every FilterSet to validate that Plural names are correctly mapped in get_filterset_parameter_form_field.
635
+ """
636
+ filterset_classes = set()
637
+ for app_config in django_apps.get_app_configs():
638
+ try:
639
+ filters_mod = sys.modules.get(f"{app_config.name}.filters")
640
+ if not filters_mod:
641
+ continue
642
+ for _name, obj in inspect.getmembers(filters_mod):
643
+ if (
644
+ inspect.isclass(obj) # Check if obj is a class
645
+ and issubclass(obj, FilterSet) # Check if obj is a subclass of FilterSet
646
+ and obj is not FilterSet # Exclude the base FilterSet class itself
647
+ and getattr(getattr(obj, "_meta", None), "model", None)
648
+ is not None # Ensure the FilterSet has a model defined
649
+ ):
650
+ filterset_classes.add(obj)
651
+ except Exception as e:
652
+ # This test might start failing if an app's filters.py gets a design change.
653
+ self.fail(f"Error processing app '{app_config.name}': {e}")
654
+ for filterset_class in filterset_classes:
655
+ filterset = filterset_class()
656
+ model = filterset._meta.model
657
+ for filter_name in filterset.filters.keys():
658
+ try:
659
+ field = get_filterset_parameter_form_field(model, filter_name, filterset=filterset)
660
+ self.assertIsNotNone(field, "Field was unexpectedly None")
661
+ except KeyError as e:
662
+ self.fail(
663
+ f"A filter failed to operate due to mismatched plural name:"
664
+ f" Check MODEL_VERBOSE_NAME_PLURAL_TO_FEATURE_NAME_MAPPING:"
665
+ f" FilterClass: {filterset_class.__name__} name: {filter_name}: {e}"
666
+ )
667
+
627
668
  # TODO(timizuo): investigate why test fails on CI
628
669
  # def test_dynamic_filter_form_with_missing_attr(self):
629
670
  # with self.assertRaises(AttributeError) as err:
@@ -17,11 +17,12 @@ from nautobot.core.jobs.cleanup import CleanupTypes
17
17
  from nautobot.core.testing import create_job_result_and_run_job, TransactionTestCase
18
18
  from nautobot.core.testing.context import load_event_broker_override_settings
19
19
  from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Manufacturer
20
- from nautobot.extras.choices import JobResultStatusChoices, LogLevelChoices
20
+ from nautobot.extras.choices import DynamicGroupTypeChoices, JobResultStatusChoices, LogLevelChoices
21
21
  from nautobot.extras.factory import JobResultFactory, ObjectChangeFactory
22
22
  from nautobot.extras.models import (
23
23
  Contact,
24
24
  ContactAssociation,
25
+ DynamicGroup,
25
26
  ExportTemplate,
26
27
  FileProxy,
27
28
  JobLogEntry,
@@ -1248,3 +1249,76 @@ class BulkDeleteTestCase(TransactionTestCase):
1248
1249
  saved_view_id=None,
1249
1250
  )
1250
1251
  self._common_no_error_test_assertion(Role, job_result, name__istartswith="Example Status")
1252
+
1253
+
1254
+ class RefreshDynamicGroupCacheJobButtonReceiverTestCase(TransactionTestCase):
1255
+ def setUp(self):
1256
+ super().setUp()
1257
+ self.job_module = "nautobot.core.jobs.groups"
1258
+ self.job_name = "RefreshDynamicGroupCacheJobButtonReceiver"
1259
+
1260
+ def test_successful_cache_refresh(self):
1261
+ LocationType.objects.create(name="DG Test LT 1")
1262
+ LocationType.objects.create(name="DG Test LT 2")
1263
+ LocationType.objects.create(name="DG Test LT 3")
1264
+ dg = DynamicGroup(
1265
+ name="Location Types",
1266
+ content_type=ContentType.objects.get_for_model(LocationType),
1267
+ group_type=DynamicGroupTypeChoices.TYPE_DYNAMIC_FILTER,
1268
+ filter={"name__isw": ["DG Test"]},
1269
+ )
1270
+ dg.clean()
1271
+ dg.save(update_cached_members=False)
1272
+ self.assertEqual(0, dg.count)
1273
+
1274
+ job_result = create_job_result_and_run_job(
1275
+ self.job_module,
1276
+ self.job_name,
1277
+ object_model_name="extras.dynamicgroup",
1278
+ object_pk=dg.pk,
1279
+ )
1280
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_SUCCESS)
1281
+ self.assertEqual(3, dg.count)
1282
+
1283
+ dg.filter = {"name__iew": ["DG Test"]}
1284
+ dg.clean()
1285
+ dg.save(update_cached_members=False)
1286
+ self.assertEqual(3, dg.count)
1287
+ job_result = create_job_result_and_run_job(
1288
+ self.job_module,
1289
+ self.job_name,
1290
+ object_model_name="extras.dynamicgroup",
1291
+ object_pk=dg.pk,
1292
+ )
1293
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_SUCCESS)
1294
+ self.assertEqual(0, dg.count)
1295
+
1296
+ def test_failure_on_non_dg(self):
1297
+ job_result = create_job_result_and_run_job(
1298
+ self.job_module,
1299
+ self.job_name,
1300
+ object_model_name="extras.status",
1301
+ object_pk=Status.objects.first().pk,
1302
+ )
1303
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1304
+ log_fail = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_FAILURE)
1305
+ self.assertEqual(log_fail.message, "This job button should only be used with Dynamic Group records.")
1306
+
1307
+ def test_failure_on_static_dg(self):
1308
+ dg = DynamicGroup.objects.create(
1309
+ name="Location Types",
1310
+ content_type=ContentType.objects.get_for_model(LocationType),
1311
+ group_type=DynamicGroupTypeChoices.TYPE_STATIC,
1312
+ )
1313
+ job_result = create_job_result_and_run_job(
1314
+ self.job_module,
1315
+ self.job_name,
1316
+ object_model_name="extras.dynamicgroup",
1317
+ object_pk=dg.pk,
1318
+ )
1319
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1320
+ log_fail = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_FAILURE)
1321
+ self.assertEqual(
1322
+ log_fail.message,
1323
+ "The members of this Dynamic Group are statically defined and do not need to be recalculated.",
1324
+ )
@@ -13,8 +13,21 @@ class TestInvalidateMaxTreeDepthSignal(TestCase):
13
13
  # Ensure that the max_depth hasn't already been cached
14
14
  Location.objects.__dict__.pop("max_depth", None)
15
15
  location = Location.objects.first()
16
- with self.assertNumQueries(1):
16
+
17
+ with CaptureQueriesContext(connection) as ctx:
17
18
  location.save()
19
+ captured_tree_cte_queries = [
20
+ query["sql"] for query in ctx.captured_queries if "WITH RECURSIVE" in query["sql"]
21
+ ]
22
+ allowed_number_of_tree_queries = 0 # We don't expect any tree queries to be run
23
+ _query_separator = "\n" + ("-" * 10) + "\n" + "NEXT QUERY" + "\n" + ("-" * 10)
24
+ self.assertEqual(
25
+ len(captured_tree_cte_queries),
26
+ allowed_number_of_tree_queries,
27
+ f"The CTE tree was calculated a different number of times ({len(captured_tree_cte_queries)})"
28
+ f" than allowed ({allowed_number_of_tree_queries})."
29
+ f" The following queries were used:\n{_query_separator.join(captured_tree_cte_queries)}",
30
+ )
18
31
 
19
32
 
20
33
  class QuerySetAncestorTests(TestCase):