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.
- nautobot/circuits/templates/circuits/circuit.html +1 -1
- nautobot/circuits/templates/circuits/circuittermination.html +1 -1
- nautobot/circuits/templates/circuits/circuittype.html +1 -1
- nautobot/circuits/templates/circuits/providernetwork.html +1 -1
- nautobot/core/cli/migrate_deprecated_templates.py +200 -0
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/jobs/groups.py +31 -1
- nautobot/core/models/tree_queries.py +10 -5
- nautobot/core/signals.py +12 -1
- nautobot/core/templates/components/panel/panel.html +1 -1
- nautobot/core/templates/inc/image_attachments.html +2 -1
- nautobot/core/templatetags/helpers.py +22 -0
- nautobot/core/tests/runner.py +3 -0
- nautobot/core/tests/test_cli.py +40 -0
- nautobot/core/tests/test_forms.py +41 -0
- nautobot/core/tests/test_jobs.py +75 -1
- nautobot/core/tests/test_tree_queries.py +14 -1
- nautobot/core/ui/object_detail.py +41 -5
- nautobot/core/utils/filtering.py +11 -9
- nautobot/core/views/generic.py +3 -3
- nautobot/dcim/models/device_components.py +81 -68
- nautobot/dcim/templates/dcim/device/config.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
- nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
- nautobot/dcim/templates/dcim/device/frontports.html +1 -1
- nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
- nautobot/dcim/templates/dcim/device/inventory.html +1 -1
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
- nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
- nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
- nautobot/dcim/templates/dcim/device/powerports.html +1 -1
- nautobot/dcim/templates/dcim/device/rearports.html +1 -1
- nautobot/dcim/templates/dcim/device/status.html +1 -1
- nautobot/dcim/templates/dcim/device/wireless.html +1 -1
- nautobot/dcim/templates/dcim/device.html +1 -1
- nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
- nautobot/dcim/templates/dcim/devicetype.html +1 -1
- nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
- nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
- nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/powerfeed.html +1 -1
- nautobot/dcim/templates/dcim/powerpanel.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
- nautobot/dcim/tests/test_models.py +43 -3
- nautobot/dcim/tests/test_views.py +52 -21
- nautobot/dcim/views.py +203 -87
- nautobot/extras/api/views.py +9 -1
- nautobot/extras/filters/customfields.py +9 -3
- nautobot/extras/models/groups.py +42 -5
- nautobot/extras/signals.py +20 -19
- nautobot/extras/tables.py +31 -2
- nautobot/extras/templates/extras/computedfield.html +1 -1
- nautobot/extras/templates/extras/configcontext.html +1 -1
- nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
- nautobot/extras/templates/extras/customfield.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
- nautobot/extras/templates/extras/gitrepository_result.html +0 -2
- nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
- nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
- nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
- nautobot/extras/templates/extras/secretsgroup.html +1 -1
- nautobot/extras/templates/extras/tag.html +1 -1
- nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
- nautobot/extras/tests/test_api.py +1 -0
- nautobot/extras/tests/test_changelog.py +28 -0
- nautobot/extras/tests/test_customfields.py +10 -2
- nautobot/extras/tests/test_dynamicgroups.py +37 -1
- nautobot/extras/views.py +49 -19
- nautobot/ipam/signals.py +71 -0
- nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
- nautobot/ipam/templates/ipam/service.html +1 -1
- nautobot/ipam/templates/ipam/vlan.html +1 -1
- nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
- nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
- nautobot/ipam/tests/test_models.py +42 -0
- nautobot/users/templates/users/sessionkey_delete.html +1 -1
- nautobot/users/views.py +2 -2
- nautobot/virtualization/models.py +1 -68
- nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
- nautobot/virtualization/tests/test_models.py +42 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
- nautobot-2.4.21.dist-info/entry_points.txt +4 -0
- nautobot-2.4.20.dist-info/entry_points.txt +0 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/WHEEL +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
{% extends '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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()
|
nautobot/core/jobs/__init__.py
CHANGED
|
@@ -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)
|
nautobot/core/jobs/groups.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,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
|
#
|
nautobot/core/tests/runner.py
CHANGED
|
@@ -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:
|
nautobot/core/tests/test_jobs.py
CHANGED
|
@@ -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
|
-
|
|
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):
|