nautobot 2.2.1__py3-none-any.whl → 2.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/core/tests/test_views.py +2 -1
- nautobot/core/views/generic.py +4 -4
- nautobot/core/views/mixins.py +4 -3
- nautobot/core/wsgi.py +9 -2
- nautobot/dcim/forms.py +8 -3
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +2 -2
- nautobot/dcim/templates/dcim/devicefamily_retrieve.html +1 -1
- nautobot/dcim/tests/test_forms.py +49 -2
- nautobot/extras/context_managers.py +56 -0
- nautobot/extras/signals.py +82 -48
- nautobot/extras/tests/test_context_managers.py +98 -1
- nautobot/extras/utils.py +37 -0
- nautobot/ipam/tables.py +1 -1
- nautobot/project-static/docs/release-notes/version-1.6.html +504 -201
- nautobot/project-static/docs/release-notes/version-2.2.html +210 -43
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +254 -254
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/METADATA +1 -1
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/RECORD +24 -24
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/NOTICE +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/WHEEL +0 -0
- {nautobot-2.2.1.dist-info → nautobot-2.2.2.dist-info}/entry_points.txt +0 -0
|
@@ -90,7 +90,8 @@ class HomeViewTestCase(TestCase):
|
|
|
90
90
|
difference = [model for model in existing_models if model not in global_searchable_models]
|
|
91
91
|
if difference:
|
|
92
92
|
self.fail(
|
|
93
|
-
f'Existing model/models {",".join(difference)} are not included in the searchable_models attribute of the app config.\
|
|
93
|
+
f'Existing model/models {",".join(difference)} are not included in the searchable_models attribute of the app config.\n'
|
|
94
|
+
'If you do not want the models to be searchable, please include them in the GLOBAL_SEARCH_EXCLUDE_LIST constant in nautobot.core.constants.'
|
|
94
95
|
)
|
|
95
96
|
|
|
96
97
|
def make_request(self):
|
nautobot/core/views/generic.py
CHANGED
|
@@ -54,9 +54,10 @@ from nautobot.core.views.utils import (
|
|
|
54
54
|
import_csv_helper,
|
|
55
55
|
prepare_cloned_fields,
|
|
56
56
|
)
|
|
57
|
+
from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
|
|
57
58
|
from nautobot.extras.models import ContactAssociation, ExportTemplate
|
|
58
59
|
from nautobot.extras.tables import AssociatedContactsTable
|
|
59
|
-
from nautobot.extras.utils import remove_prefix_from_cf_key
|
|
60
|
+
from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, remove_prefix_from_cf_key
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
class GenericView(LoginRequiredMixin, View):
|
|
@@ -988,7 +989,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
988
989
|
nullified_fields = request.POST.getlist("_nullify")
|
|
989
990
|
|
|
990
991
|
try:
|
|
991
|
-
with
|
|
992
|
+
with deferred_change_logging_for_bulk_operation():
|
|
992
993
|
updated_objects = []
|
|
993
994
|
for obj in self.queryset.filter(pk__in=form.cleaned_data["pk"]):
|
|
994
995
|
obj = self.alter_obj(obj, request, [], kwargs)
|
|
@@ -1252,13 +1253,12 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|
|
1252
1253
|
|
|
1253
1254
|
self.perform_pre_delete(request, queryset)
|
|
1254
1255
|
try:
|
|
1255
|
-
_, deleted_info = queryset
|
|
1256
|
+
_, deleted_info = bulk_delete_with_bulk_change_logging(queryset)
|
|
1256
1257
|
deleted_count = deleted_info[model._meta.label]
|
|
1257
1258
|
except ProtectedError as e:
|
|
1258
1259
|
logger.info("Caught ProtectedError while attempting to delete objects")
|
|
1259
1260
|
handle_protectederror(queryset, request, e)
|
|
1260
1261
|
return redirect(self.get_return_url(request))
|
|
1261
|
-
|
|
1262
1262
|
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
|
1263
1263
|
logger.info(msg)
|
|
1264
1264
|
messages.success(request, msg)
|
nautobot/core/views/mixins.py
CHANGED
|
@@ -45,10 +45,11 @@ from nautobot.core.views.utils import (
|
|
|
45
45
|
import_csv_helper,
|
|
46
46
|
prepare_cloned_fields,
|
|
47
47
|
)
|
|
48
|
+
from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
|
|
48
49
|
from nautobot.extras.forms import NoteForm
|
|
49
50
|
from nautobot.extras.models import ExportTemplate
|
|
50
51
|
from nautobot.extras.tables import NoteTable, ObjectChangeTable
|
|
51
|
-
from nautobot.extras.utils import remove_prefix_from_cf_key
|
|
52
|
+
from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, remove_prefix_from_cf_key
|
|
52
53
|
|
|
53
54
|
PERMISSIONS_ACTION_MAP = {
|
|
54
55
|
"list": "view",
|
|
@@ -846,7 +847,7 @@ class ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin):
|
|
|
846
847
|
|
|
847
848
|
try:
|
|
848
849
|
with transaction.atomic():
|
|
849
|
-
deleted_count = queryset
|
|
850
|
+
deleted_count = bulk_delete_with_bulk_change_logging(queryset)[1][model._meta.label]
|
|
850
851
|
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
|
851
852
|
self.logger.info(msg)
|
|
852
853
|
self.success_url = self.get_return_url(request)
|
|
@@ -978,7 +979,7 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin):
|
|
|
978
979
|
if field not in form_custom_fields + form_relationships + ["pk"] + ["object_note"]
|
|
979
980
|
]
|
|
980
981
|
nullified_fields = request.POST.getlist("_nullify")
|
|
981
|
-
with
|
|
982
|
+
with deferred_change_logging_for_bulk_operation():
|
|
982
983
|
updated_objects = []
|
|
983
984
|
for obj in queryset.filter(pk__in=form.cleaned_data["pk"]):
|
|
984
985
|
self.obj = obj
|
nautobot/core/wsgi.py
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import os
|
|
3
2
|
|
|
4
3
|
from django.core import cache
|
|
5
4
|
from django.core.wsgi import get_wsgi_application
|
|
6
5
|
from django.db import connections
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
import nautobot
|
|
8
|
+
|
|
9
|
+
# This is the Django default left here for visibility on how the Nautobot pattern
|
|
10
|
+
# differs.
|
|
11
|
+
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nautobot.core.settings")
|
|
12
|
+
|
|
13
|
+
# Instead of just pointing to `DJANGO_SETTINGS_MODULE` and letting Django run with it,
|
|
14
|
+
# we're using the custom Nautobot loader code to read environment or config path for us.
|
|
15
|
+
nautobot.setup()
|
|
9
16
|
|
|
10
17
|
# Use try/except because we might not be running uWSGI. If `settings.WEBSERVER_WARMUP` is `True`,
|
|
11
18
|
# will first call `get_internal_wsgi_application` which does not have `uwsgi` module loaded
|
nautobot/dcim/forms.py
CHANGED
|
@@ -55,7 +55,7 @@ from nautobot.extras.forms import (
|
|
|
55
55
|
)
|
|
56
56
|
from nautobot.extras.models import ExternalIntegration, SecretsGroup, Status
|
|
57
57
|
from nautobot.ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
|
58
|
-
from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VRF
|
|
58
|
+
from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VLANLocationAssignment, VRF
|
|
59
59
|
from nautobot.tenancy.forms import TenancyFilterForm, TenancyForm
|
|
60
60
|
from nautobot.tenancy.models import Tenant, TenantGroup
|
|
61
61
|
from nautobot.virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
|
@@ -194,8 +194,13 @@ class InterfaceCommonForm(forms.Form):
|
|
|
194
194
|
# TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
|
|
195
195
|
# belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
|
|
196
196
|
elif mode == InterfaceModeChoices.MODE_TAGGED:
|
|
197
|
-
|
|
198
|
-
invalid_vlans = [
|
|
197
|
+
valid_location = self.cleaned_data[parent_field].location
|
|
198
|
+
invalid_vlans = [
|
|
199
|
+
str(v)
|
|
200
|
+
for v in tagged_vlans
|
|
201
|
+
if v.locations.without_tree_fields().exists()
|
|
202
|
+
and not VLANLocationAssignment.objects.filter(location=valid_location, vlan=v).exists()
|
|
203
|
+
]
|
|
199
204
|
|
|
200
205
|
if invalid_vlans:
|
|
201
206
|
raise forms.ValidationError(
|
|
@@ -65,7 +65,7 @@ var ready = (callback) => {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
function getAttribute(node, querySelector, attribute) {
|
|
68
|
-
if (node === null
|
|
68
|
+
if (node === null || node.querySelector(querySelector) === null) {
|
|
69
69
|
return "";
|
|
70
70
|
}
|
|
71
71
|
return node.querySelector(querySelector).getAttribute(attribute) || "";
|
|
@@ -131,4 +131,4 @@ ready(() => {
|
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
</script>
|
|
134
|
-
{% endblock %}
|
|
134
|
+
{% endblock %}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from django.test import TestCase
|
|
2
2
|
|
|
3
3
|
from nautobot.core.testing.forms import FormTestCases
|
|
4
|
-
from nautobot.dcim.choices import DeviceFaceChoices, InterfaceTypeChoices, RackWidthChoices
|
|
5
|
-
from nautobot.dcim.forms import DeviceFilterForm, DeviceForm, InterfaceCreateForm, RackForm
|
|
4
|
+
from nautobot.dcim.choices import DeviceFaceChoices, InterfaceModeChoices, InterfaceTypeChoices, RackWidthChoices
|
|
5
|
+
from nautobot.dcim.forms import DeviceFilterForm, DeviceForm, InterfaceCreateForm, InterfaceForm, RackForm
|
|
6
6
|
from nautobot.dcim.models import (
|
|
7
7
|
Device,
|
|
8
8
|
DeviceType,
|
|
@@ -14,6 +14,7 @@ from nautobot.dcim.models import (
|
|
|
14
14
|
Rack,
|
|
15
15
|
)
|
|
16
16
|
from nautobot.extras.models import Role, SecretsGroup, Status
|
|
17
|
+
from nautobot.ipam.models import VLAN
|
|
17
18
|
from nautobot.tenancy.models import Tenant
|
|
18
19
|
from nautobot.virtualization.models import Cluster, ClusterGroup, ClusterType
|
|
19
20
|
|
|
@@ -263,3 +264,49 @@ class RackTestCase(TestCase):
|
|
|
263
264
|
}
|
|
264
265
|
form = RackForm(data=data, instance=racks[0])
|
|
265
266
|
self.assertTrue(form.is_valid())
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class InterfaceTestCase(TestCase):
|
|
270
|
+
@classmethod
|
|
271
|
+
def setUpTestData(cls):
|
|
272
|
+
cls.device = Device.objects.first()
|
|
273
|
+
status = Status.objects.get_for_model(Interface).first()
|
|
274
|
+
cls.interface = Interface.objects.create(
|
|
275
|
+
device=cls.device,
|
|
276
|
+
name="test interface form 0.0",
|
|
277
|
+
type=InterfaceTypeChoices.TYPE_2GFC_SFP,
|
|
278
|
+
status=status,
|
|
279
|
+
)
|
|
280
|
+
cls.vlan = VLAN.objects.first()
|
|
281
|
+
cls.data = {
|
|
282
|
+
"device": cls.device.pk,
|
|
283
|
+
"name": "test interface form 0.0",
|
|
284
|
+
"type": InterfaceTypeChoices.TYPE_2GFC_SFP,
|
|
285
|
+
"status": status.pk,
|
|
286
|
+
"mode": InterfaceModeChoices.MODE_TAGGED,
|
|
287
|
+
"tagged_vlans": [cls.vlan.pk],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def test_interface_form_clean_vlan_location_fail(self):
|
|
291
|
+
"""Assert that form validation fails when no matching locations are associated to tagged VLAN"""
|
|
292
|
+
self.vlan.locations.set(list(Location.objects.exclude(pk=self.device.location.pk))[:2])
|
|
293
|
+
form = InterfaceForm(data=self.data, instance=self.interface)
|
|
294
|
+
self.assertFalse(form.is_valid())
|
|
295
|
+
|
|
296
|
+
def test_interface_vlan_location_clean_multiple_locations_pass(self):
|
|
297
|
+
"""Assert that form validation passes when multiple locations are associated to tagged VLAN with one matching"""
|
|
298
|
+
self.vlan.locations.add(self.device.location)
|
|
299
|
+
form = InterfaceForm(data=self.data, instance=self.interface)
|
|
300
|
+
self.assertTrue(form.is_valid())
|
|
301
|
+
|
|
302
|
+
def test_interface_vlan_location_clean_single_location_pass(self):
|
|
303
|
+
"""Assert that form validation passes when a single location is associated to tagged VLAN"""
|
|
304
|
+
self.vlan.locations.set([self.device.location])
|
|
305
|
+
form = InterfaceForm(data=self.data, instance=self.interface)
|
|
306
|
+
self.assertTrue(form.is_valid())
|
|
307
|
+
|
|
308
|
+
def test_interface_vlan_location_clean_no_locations_pass(self):
|
|
309
|
+
"""Assert that form validation passes when no locations are associated to tagged VLAN"""
|
|
310
|
+
self.vlan.locations.clear()
|
|
311
|
+
form = InterfaceForm(data=self.data, instance=self.interface)
|
|
312
|
+
self.assertTrue(form.is_valid())
|
|
@@ -3,9 +3,11 @@ import uuid
|
|
|
3
3
|
|
|
4
4
|
from django.contrib.auth import get_user_model
|
|
5
5
|
from django.contrib.auth.models import AnonymousUser
|
|
6
|
+
from django.db import transaction
|
|
6
7
|
from django.test.client import RequestFactory
|
|
7
8
|
|
|
8
9
|
from nautobot.extras.choices import ObjectChangeEventContextChoices
|
|
10
|
+
from nautobot.extras.constants import CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
|
|
9
11
|
from nautobot.extras.models import ObjectChange
|
|
10
12
|
from nautobot.extras.signals import change_context_state, get_user_if_authenticated
|
|
11
13
|
from nautobot.extras.webhooks import enqueue_webhooks
|
|
@@ -25,9 +27,12 @@ class ChangeContext:
|
|
|
25
27
|
:param change_id: Optional uuid object to uniquely identify the transaction. One will be generated if not supplied
|
|
26
28
|
"""
|
|
27
29
|
|
|
30
|
+
defer_object_changes = False # advanced usage, for creating object changes in bulk
|
|
31
|
+
|
|
28
32
|
def __init__(self, user=None, request=None, context=None, context_detail="", change_id=None):
|
|
29
33
|
self.request = request
|
|
30
34
|
self.user = user
|
|
35
|
+
self.reset_deferred_object_changes()
|
|
31
36
|
|
|
32
37
|
if self.request is None and self.user is None:
|
|
33
38
|
raise TypeError("Either user or request must be provided")
|
|
@@ -63,6 +68,36 @@ class ChangeContext:
|
|
|
63
68
|
}
|
|
64
69
|
return context
|
|
65
70
|
|
|
71
|
+
def _object_change_batch(self, n):
|
|
72
|
+
# Return first n keys from the self.deferred_object_changes dict
|
|
73
|
+
keys = []
|
|
74
|
+
for i, k in enumerate(self.deferred_object_changes.keys()):
|
|
75
|
+
if i >= n:
|
|
76
|
+
return keys
|
|
77
|
+
keys.append(k)
|
|
78
|
+
return keys
|
|
79
|
+
|
|
80
|
+
def reset_deferred_object_changes(self):
|
|
81
|
+
self.deferred_object_changes = {}
|
|
82
|
+
|
|
83
|
+
def flush_deferred_object_changes(self, batch_size=1000):
|
|
84
|
+
if self.defer_object_changes:
|
|
85
|
+
self.create_object_changes(batch_size=batch_size)
|
|
86
|
+
|
|
87
|
+
def create_object_changes(self, batch_size=1000):
|
|
88
|
+
while self.deferred_object_changes:
|
|
89
|
+
create_object_changes = []
|
|
90
|
+
for key in self._object_change_batch(batch_size):
|
|
91
|
+
for entry in self.deferred_object_changes[key]:
|
|
92
|
+
objectchange = entry["instance"].to_objectchange(entry["action"])
|
|
93
|
+
objectchange.user = entry["user"]
|
|
94
|
+
objectchange.request_id = self.change_id
|
|
95
|
+
objectchange.change_context = self.context
|
|
96
|
+
objectchange.change_context_detail = self.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]
|
|
97
|
+
create_object_changes.append(objectchange)
|
|
98
|
+
self.deferred_object_changes.pop(key, None)
|
|
99
|
+
ObjectChange.objects.bulk_create(create_object_changes, batch_size=batch_size)
|
|
100
|
+
|
|
66
101
|
|
|
67
102
|
class JobChangeContext(ChangeContext):
|
|
68
103
|
"""ChangeContext for changes made by jobs"""
|
|
@@ -163,3 +198,24 @@ def web_request_context(
|
|
|
163
198
|
for object_change in ObjectChange.objects.filter(request_id=change_context.change_id).iterator():
|
|
164
199
|
enqueue_job_hooks(object_change)
|
|
165
200
|
enqueue_webhooks(object_change)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def deferred_change_logging_for_bulk_operation():
|
|
205
|
+
"""
|
|
206
|
+
Defers change logging until the end of the context manager to improve performance. For use with bulk edit views. This
|
|
207
|
+
context manager is wrapped in an atomic transaction.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
change_context = change_context_state.get()
|
|
211
|
+
if change_context is None:
|
|
212
|
+
raise ValueError("Change logging must be enabled before using deferred_change_logging_for_bulk_operation")
|
|
213
|
+
|
|
214
|
+
with transaction.atomic():
|
|
215
|
+
try:
|
|
216
|
+
change_context.defer_object_changes = True
|
|
217
|
+
yield
|
|
218
|
+
change_context.flush_deferred_object_changes()
|
|
219
|
+
finally:
|
|
220
|
+
change_context.defer_object_changes = False
|
|
221
|
+
change_context.reset_deferred_object_changes()
|
nautobot/extras/signals.py
CHANGED
|
@@ -103,7 +103,9 @@ def _handle_changed_object(sender, instance, raw=False, **kwargs):
|
|
|
103
103
|
if raw:
|
|
104
104
|
return
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
change_context = change_context_state.get()
|
|
107
|
+
|
|
108
|
+
if change_context is None:
|
|
107
109
|
return
|
|
108
110
|
|
|
109
111
|
# Determine the type of change being made
|
|
@@ -119,36 +121,49 @@ def _handle_changed_object(sender, instance, raw=False, **kwargs):
|
|
|
119
121
|
|
|
120
122
|
# Record an ObjectChange if applicable
|
|
121
123
|
if hasattr(instance, "to_objectchange"):
|
|
122
|
-
user =
|
|
124
|
+
user = change_context.get_user(instance)
|
|
123
125
|
# save a copy of this instance's field cache so it can be restored after serialization
|
|
124
126
|
# to prevent unexpected behavior when chaining multiple signal handlers
|
|
125
127
|
original_cache = instance._state.fields_cache.copy()
|
|
126
128
|
|
|
129
|
+
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
130
|
+
changed_object_id = instance.id
|
|
131
|
+
|
|
132
|
+
# Generate a unique identifier for this change to stash in the change context
|
|
133
|
+
# This is used for deferred change logging and for looking up related changes without querying the database
|
|
134
|
+
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
|
|
135
|
+
|
|
127
136
|
# If a change already exists for this change_id, user, and object, update it instead of creating a new one.
|
|
128
137
|
# If the object was deleted then recreated with the same pk (don't do this), change the action to update.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
if unique_object_change_id in change_context.deferred_object_changes:
|
|
139
|
+
related_changes = ObjectChange.objects.filter(
|
|
140
|
+
changed_object_type=changed_object_type,
|
|
141
|
+
changed_object_id=changed_object_id,
|
|
142
|
+
user=user,
|
|
143
|
+
request_id=change_context.change_id,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Skip the database check when deferring object changes
|
|
147
|
+
if not change_context.defer_object_changes and related_changes.exists():
|
|
148
|
+
objectchange = instance.to_objectchange(action)
|
|
149
|
+
most_recent_change = related_changes.order_by("-time").first()
|
|
150
|
+
if most_recent_change.action == ObjectChangeActionChoices.ACTION_DELETE:
|
|
151
|
+
most_recent_change.action = ObjectChangeActionChoices.ACTION_UPDATE
|
|
152
|
+
most_recent_change.object_data = objectchange.object_data
|
|
153
|
+
most_recent_change.object_data_v2 = objectchange.object_data_v2
|
|
154
|
+
most_recent_change.save()
|
|
155
|
+
|
|
144
156
|
else:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
objectchange.change_context = change_context_state.get().context
|
|
148
|
-
objectchange.change_context_detail = change_context_state.get().context_detail[
|
|
149
|
-
:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
|
|
157
|
+
change_context.deferred_object_changes[unique_object_change_id] = [
|
|
158
|
+
{"action": action, "instance": instance, "user": user}
|
|
150
159
|
]
|
|
151
|
-
|
|
160
|
+
if not change_context.defer_object_changes:
|
|
161
|
+
objectchange = instance.to_objectchange(action)
|
|
162
|
+
objectchange.user = user
|
|
163
|
+
objectchange.request_id = change_context.change_id
|
|
164
|
+
objectchange.change_context = change_context.context
|
|
165
|
+
objectchange.change_context_detail = change_context.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]
|
|
166
|
+
objectchange.save()
|
|
152
167
|
|
|
153
168
|
# restore field cache
|
|
154
169
|
instance._state.fields_cache = original_cache
|
|
@@ -171,7 +186,9 @@ def _handle_deleted_object(sender, instance, **kwargs):
|
|
|
171
186
|
"""
|
|
172
187
|
Fires when an object is deleted.
|
|
173
188
|
"""
|
|
174
|
-
|
|
189
|
+
change_context = change_context_state.get()
|
|
190
|
+
|
|
191
|
+
if change_context is None:
|
|
175
192
|
return
|
|
176
193
|
|
|
177
194
|
if isinstance(instance, BaseModel):
|
|
@@ -186,41 +203,58 @@ def _handle_deleted_object(sender, instance, **kwargs):
|
|
|
186
203
|
|
|
187
204
|
# Record an ObjectChange if applicable
|
|
188
205
|
if hasattr(instance, "to_objectchange"):
|
|
189
|
-
user =
|
|
206
|
+
user = change_context.get_user(instance)
|
|
190
207
|
|
|
191
208
|
# save a copy of this instance's field cache so it can be restored after serialization
|
|
192
209
|
# to prevent unexpected behavior when chaining multiple signal handlers
|
|
193
210
|
original_cache = instance._state.fields_cache.copy()
|
|
194
211
|
|
|
212
|
+
changed_object_type = ContentType.objects.get_for_model(instance)
|
|
213
|
+
changed_object_id = instance.id
|
|
214
|
+
|
|
215
|
+
# Generate a unique identifier for this change to stash in the change context
|
|
216
|
+
# This is used for deferred change logging and for looking up related changes without querying the database
|
|
217
|
+
unique_object_change_id = f"{changed_object_type.pk}__{changed_object_id}__{user.pk}"
|
|
218
|
+
save_new_objectchange = True
|
|
219
|
+
|
|
195
220
|
# if a change already exists for this change_id, user, and object, update it instead of creating a new one
|
|
196
221
|
# except in the case that the object was created and deleted in the same change_id
|
|
197
222
|
# we don't want to create a delete change for an object that never existed
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
request_id=change_context_state.get().change_id,
|
|
203
|
-
)
|
|
204
|
-
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
205
|
-
save_new_objectchange = True
|
|
206
|
-
if related_changes.exists():
|
|
207
|
-
most_recent_change = related_changes.order_by("-time").first()
|
|
208
|
-
if most_recent_change.action != ObjectChangeActionChoices.ACTION_CREATE:
|
|
209
|
-
most_recent_change.action = ObjectChangeActionChoices.ACTION_DELETE
|
|
210
|
-
most_recent_change.object_data = objectchange.object_data
|
|
211
|
-
most_recent_change.object_data_v2 = objectchange.object_data_v2
|
|
212
|
-
most_recent_change.save()
|
|
213
|
-
objectchange = most_recent_change
|
|
223
|
+
if unique_object_change_id in change_context.deferred_object_changes:
|
|
224
|
+
cached_related_change = change_context.deferred_object_changes[unique_object_change_id][-1]
|
|
225
|
+
if cached_related_change["action"] != ObjectChangeActionChoices.ACTION_CREATE:
|
|
226
|
+
cached_related_change["action"] = ObjectChangeActionChoices.ACTION_DELETE
|
|
214
227
|
save_new_objectchange = False
|
|
215
228
|
|
|
229
|
+
related_changes = ObjectChange.objects.filter(
|
|
230
|
+
changed_object_type=changed_object_type,
|
|
231
|
+
changed_object_id=changed_object_id,
|
|
232
|
+
user=user,
|
|
233
|
+
request_id=change_context.change_id,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Skip the database check when deferring object changes
|
|
237
|
+
if not change_context.defer_object_changes and related_changes.exists():
|
|
238
|
+
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
239
|
+
most_recent_change = related_changes.order_by("-time").first()
|
|
240
|
+
if most_recent_change.action != ObjectChangeActionChoices.ACTION_CREATE:
|
|
241
|
+
most_recent_change.action = ObjectChangeActionChoices.ACTION_DELETE
|
|
242
|
+
most_recent_change.object_data = objectchange.object_data
|
|
243
|
+
most_recent_change.object_data_v2 = objectchange.object_data_v2
|
|
244
|
+
most_recent_change.save()
|
|
245
|
+
save_new_objectchange = False
|
|
246
|
+
|
|
216
247
|
if save_new_objectchange:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
248
|
+
change_context.deferred_object_changes.setdefault(unique_object_change_id, []).append(
|
|
249
|
+
{"action": ObjectChangeActionChoices.ACTION_DELETE, "instance": instance, "user": user}
|
|
250
|
+
)
|
|
251
|
+
if not change_context.defer_object_changes:
|
|
252
|
+
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
253
|
+
objectchange.user = user
|
|
254
|
+
objectchange.request_id = change_context.change_id
|
|
255
|
+
objectchange.change_context = change_context.context
|
|
256
|
+
objectchange.change_context_detail = change_context.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]
|
|
257
|
+
objectchange.save()
|
|
224
258
|
|
|
225
259
|
# restore field cache
|
|
226
260
|
instance._state.fields_cache = original_cache
|
|
@@ -7,7 +7,10 @@ from nautobot.core.testing import TransactionTestCase
|
|
|
7
7
|
from nautobot.core.utils.lookup import get_changes_for_model
|
|
8
8
|
from nautobot.dcim.models import Location, LocationType
|
|
9
9
|
from nautobot.extras.choices import ObjectChangeActionChoices, ObjectChangeEventContextChoices
|
|
10
|
-
from nautobot.extras.context_managers import
|
|
10
|
+
from nautobot.extras.context_managers import (
|
|
11
|
+
deferred_change_logging_for_bulk_operation,
|
|
12
|
+
web_request_context,
|
|
13
|
+
)
|
|
11
14
|
from nautobot.extras.models import Status, Webhook
|
|
12
15
|
|
|
13
16
|
# Use the proper swappable User model
|
|
@@ -193,3 +196,97 @@ class WebRequestContextTransactionTestCase(TransactionTestCase):
|
|
|
193
196
|
Status.objects.create(name="Test Status 2")
|
|
194
197
|
|
|
195
198
|
self.assertEqual(get_changes_for_model(Status).count(), 2)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class BulkEditDeleteChangeLogging(TestCase):
|
|
202
|
+
def setUp(self):
|
|
203
|
+
self.user = User.objects.create_user(
|
|
204
|
+
username="jacob",
|
|
205
|
+
email="jacob@example.com",
|
|
206
|
+
password="top_secret", # noqa: S106 # hardcoded-password-func-arg -- ok as this is test code only
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def test_change_log_created(self):
|
|
210
|
+
location_type = LocationType.objects.get(name="Campus")
|
|
211
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
212
|
+
with web_request_context(self.user):
|
|
213
|
+
with deferred_change_logging_for_bulk_operation():
|
|
214
|
+
location = Location(name="Test Location 1", location_type=location_type, status=location_status)
|
|
215
|
+
location.save()
|
|
216
|
+
|
|
217
|
+
location = Location.objects.get(name="Test Location 1")
|
|
218
|
+
oc_list = get_changes_for_model(location).order_by("pk")
|
|
219
|
+
self.assertEqual(len(oc_list), 1)
|
|
220
|
+
self.assertEqual(oc_list[0].changed_object, location)
|
|
221
|
+
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
|
222
|
+
|
|
223
|
+
def test_delete(self):
|
|
224
|
+
"""Test that deletes raise an exception"""
|
|
225
|
+
location_type = LocationType.objects.get(name="Campus")
|
|
226
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
227
|
+
with self.assertRaises(ValueError):
|
|
228
|
+
with web_request_context(self.user):
|
|
229
|
+
with deferred_change_logging_for_bulk_operation():
|
|
230
|
+
location = Location(name="Test Location 1", location_type=location_type, status=location_status)
|
|
231
|
+
location.save()
|
|
232
|
+
location.delete()
|
|
233
|
+
|
|
234
|
+
def test_create_then_update(self):
|
|
235
|
+
"""Test that a create followed by an update is logged as a single create"""
|
|
236
|
+
location_type = LocationType.objects.get(name="Campus")
|
|
237
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
238
|
+
with web_request_context(self.user):
|
|
239
|
+
with deferred_change_logging_for_bulk_operation():
|
|
240
|
+
location = Location(name="Test Location 1", location_type=location_type, status=location_status)
|
|
241
|
+
location.save()
|
|
242
|
+
location.description = "changed"
|
|
243
|
+
location.save()
|
|
244
|
+
|
|
245
|
+
oc_list = get_changes_for_model(location)
|
|
246
|
+
self.assertEqual(len(oc_list), 1)
|
|
247
|
+
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
|
248
|
+
snapshots = oc_list[0].get_snapshots()
|
|
249
|
+
self.assertIsNone(snapshots["prechange"])
|
|
250
|
+
self.assertIsNotNone(snapshots["postchange"])
|
|
251
|
+
self.assertIsNone(snapshots["differences"]["removed"])
|
|
252
|
+
self.assertEqual(snapshots["differences"]["added"]["description"], "changed")
|
|
253
|
+
|
|
254
|
+
def test_bulk_edit(self):
|
|
255
|
+
"""Test that edits to multiple objects are correctly logged"""
|
|
256
|
+
location_type = LocationType.objects.get(name="Campus")
|
|
257
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
258
|
+
locations = [
|
|
259
|
+
Location(name=f"Test Location {i}", location_type=location_type, status=location_status)
|
|
260
|
+
for i in range(1, 4)
|
|
261
|
+
]
|
|
262
|
+
Location.objects.bulk_create(locations)
|
|
263
|
+
with web_request_context(self.user):
|
|
264
|
+
with deferred_change_logging_for_bulk_operation():
|
|
265
|
+
for location in locations:
|
|
266
|
+
location.description = "changed"
|
|
267
|
+
location.save()
|
|
268
|
+
|
|
269
|
+
oc_list = get_changes_for_model(Location)
|
|
270
|
+
self.assertEqual(len(oc_list), 3)
|
|
271
|
+
for oc in oc_list:
|
|
272
|
+
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
|
273
|
+
snapshots = oc.get_snapshots()
|
|
274
|
+
self.assertIsNone(snapshots["prechange"])
|
|
275
|
+
self.assertIsNotNone(snapshots["postchange"])
|
|
276
|
+
self.assertIsNone(snapshots["differences"]["removed"])
|
|
277
|
+
self.assertEqual(snapshots["differences"]["added"]["description"], "changed")
|
|
278
|
+
|
|
279
|
+
def test_change_log_context(self):
|
|
280
|
+
location_type = LocationType.objects.get(name="Campus")
|
|
281
|
+
location_status = Status.objects.get_for_model(Location).first()
|
|
282
|
+
with web_request_context(self.user, context_detail="test_change_log_context"):
|
|
283
|
+
with deferred_change_logging_for_bulk_operation():
|
|
284
|
+
location = Location(name="Test Location 1", location_type=location_type, status=location_status)
|
|
285
|
+
location.save()
|
|
286
|
+
|
|
287
|
+
location = Location.objects.get(name="Test Location 1")
|
|
288
|
+
oc_list = get_changes_for_model(location)
|
|
289
|
+
with self.subTest():
|
|
290
|
+
self.assertEqual(oc_list[0].change_context, ObjectChangeEventContextChoices.CONTEXT_ORM)
|
|
291
|
+
with self.subTest():
|
|
292
|
+
self.assertEqual(oc_list[0].change_context_detail, "test_change_log_context")
|
nautobot/extras/utils.py
CHANGED
|
@@ -19,7 +19,9 @@ from nautobot.core.choices import ColorChoices
|
|
|
19
19
|
from nautobot.core.constants import CHARFIELD_MAX_LENGTH
|
|
20
20
|
from nautobot.core.models.managers import TagsManager
|
|
21
21
|
from nautobot.core.models.utils import find_models_with_matching_fields
|
|
22
|
+
from nautobot.extras.choices import ObjectChangeActionChoices
|
|
22
23
|
from nautobot.extras.constants import (
|
|
24
|
+
CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL,
|
|
23
25
|
EXTRAS_FEATURES,
|
|
24
26
|
JOB_MAX_NAME_LENGTH,
|
|
25
27
|
JOB_OVERRIDABLE_FIELDS,
|
|
@@ -610,3 +612,38 @@ def migrate_role_data(
|
|
|
610
612
|
model_to_migrate._meta.label,
|
|
611
613
|
to_role_field_name,
|
|
612
614
|
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def bulk_delete_with_bulk_change_logging(qs, batch_size=1000):
|
|
618
|
+
"""
|
|
619
|
+
Deletes objects in the provided queryset and creates ObjectChange instances in bulk to improve performance.
|
|
620
|
+
For use with bulk delete views. This operation is wrapped in an atomic transaction.
|
|
621
|
+
"""
|
|
622
|
+
from nautobot.extras.models import ObjectChange
|
|
623
|
+
from nautobot.extras.signals import change_context_state
|
|
624
|
+
|
|
625
|
+
change_context = change_context_state.get()
|
|
626
|
+
if change_context is None:
|
|
627
|
+
raise ValueError("Change logging must be enabled before using bulk_delete_with_bulk_change_logging")
|
|
628
|
+
|
|
629
|
+
with transaction.atomic():
|
|
630
|
+
try:
|
|
631
|
+
queued_object_changes = []
|
|
632
|
+
change_context.defer_object_changes = True
|
|
633
|
+
for obj in qs.iterator():
|
|
634
|
+
if not hasattr(obj, "to_objectchange"):
|
|
635
|
+
break
|
|
636
|
+
if len(queued_object_changes) >= batch_size:
|
|
637
|
+
ObjectChange.objects.bulk_create(queued_object_changes)
|
|
638
|
+
queued_object_changes = []
|
|
639
|
+
oc = obj.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
|
640
|
+
oc.user = change_context.user
|
|
641
|
+
oc.request_id = change_context.change_id
|
|
642
|
+
oc.change_context = change_context.context
|
|
643
|
+
oc.change_context_detail = change_context.context_detail[:CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL]
|
|
644
|
+
queued_object_changes.append(oc)
|
|
645
|
+
ObjectChange.objects.bulk_create(queued_object_changes)
|
|
646
|
+
return qs.delete()
|
|
647
|
+
finally:
|
|
648
|
+
change_context.defer_object_changes = False
|
|
649
|
+
change_context.reset_deferred_object_changes()
|
nautobot/ipam/tables.py
CHANGED
|
@@ -771,7 +771,7 @@ class InterfaceVLANTable(StatusTableMixin, BaseTable):
|
|
|
771
771
|
tenant = TenantColumn()
|
|
772
772
|
role = tables.TemplateColumn(template_code=VLAN_ROLE_LINK)
|
|
773
773
|
location_count = LinkedCountColumn(
|
|
774
|
-
viewname="dcim:
|
|
774
|
+
viewname="dcim:location_list",
|
|
775
775
|
url_params={"vlans": "pk"},
|
|
776
776
|
verbose_name="Locations",
|
|
777
777
|
)
|